DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

Building a Nebula keep-list: what to exclude from renaming (and why)

Every obfuscator gives you a way to say "do not rename this." The temptation, the first time something breaks, is to reach for it liberally — exclude the type, exclude the namespace, exclude anything that looks fragile, and move on. That works, and it quietly dismantles your protection. A keep-list is not a junk drawer for whatever broke; it is a designed, minimal boundary between the names your program resolves as strings at runtime and everything else. This post is about the discipline of building that boundary and keeping it small: what genuinely has to stay un-renamed, how to say so in config, how to narrow every entry to the least you can get away with, and how to prove the result in a decompiler.

If you want the catalog of what breaks and why, that is obfuscation, reflection and serialization pitfalls. This is the companion process piece: the pitfalls post names the symptom; this one is how to write and maintain the ruleset that prevents it — without over-excluding and giving the protection away.

Why the keep-list is a cost, not a safety net

Start from the right mental model: every name on your keep-list is a name you hand an attacker in cleartext. Renaming is the cheapest, broadest protection you have — it turns ValidateLicenseKey into a across the whole assembly. Each keep entry carves a readable hole back into that. Keep a whole namespace "to be safe" and you have left that namespace's every type, method and field name sitting in the metadata for anyone with a decompiler to read.

So the governing rule is minimalism: keep a name only when something resolves it by its string at runtime, and keep only that name — not the type around it, not the namespace around the type. A keep-list built by this rule is short, and short is the point.

What actually has to stay

Across real .NET apps the same categories recur. Each is a place where a name is read as a string at runtime — by your own code, a framework, a serializer or a host — so renaming it changes a contract rather than merely hiding an identifier.

The public API surface of a shipped library. If other people compile against your assembly, every public type and member name is part of your contract; rename it and their code no longer links. This is the one category you should almost never write by hand — turn on preservePublicApi and the obfuscator keeps exactly the externally visible surface while renaming everything internal. The whole-library case is covered in protecting a shipped .NET SDK or library.

Types and members reached by reflection. Anything found through Type.GetType("..."), GetMethod("..."), GetProperty("...") or Activator.CreateInstance is resolved by a string that renaming does not touch. Keep those specific members — not, as a reflex, their whole declaring type.

Serialized DTO member names. System.Text.Json, Newtonsoft.Json and the XML serializers build their wire format from property names. Rename Invoice.Total and the JSON silently becomes {"a":…}, breaking every consumer and every stored document. Keep these — or, far better, pin them with [JsonPropertyName("total")] so the wire name stops depending on the C# name at all, as in pinning a JSON contract with JsonPropertyName.

Configuration-binding option classes. Configure<TOptions> matches configuration keys to property names through the binder. These are the single most-forgotten category, because options classes are usually internal POCOs that preservePublicApi does not cover — so they rename silently and fall back to defaults in production. Keep their bound property names.

DI convention-scanned types. If a container registers services by scanning for a name pattern — every type whose name ends in Service, say — renaming erases the convention and nothing registers. Prefer fixing the registration to be explicit or marker-based; where you cannot, keep the names the scan matches. The full treatment is in obfuscating assembly scanning and dependency injection.

Types referenced by name in XAML or markup. A {Binding CustomerName}, an x:Class, a DataTemplate type reference, a converter named in markup — all resolve strings at runtime. Keep the property and type names the markup names.

Entry points a host loads by name. A plugin the host activates by type name, a Startup class a host looks up, a module a framework loads from a manifest string — if something outside your assembly names the entry point, keep it.

Expressing it as rules

Nebula.NET reads a nebula.config.json with a renaming ruleset evaluated top to bottom — the first matching pattern wins, so put your narrow keep rules above the broad rename catch-all. Here is a keep-list for a shipped library split into a public surface, a serialized-contracts namespace, an options namespace and an internal engine:

{
  "schemaVersion": 1,
  "inputs": [ "bin/Release/net8.0/Acme.Sdk.dll" ],
  "outputDirectory": "protected",

  "preservePublicApi": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "Acme.Sdk.Contracts.*", "action": "keep" },
      { "pattern": "Acme.Sdk.Options.*",   "action": "keep", "members": "public" },
      { "pattern": "Acme.Sdk.*",           "action": "rename" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

preservePublicApi carries the externally visible API, so there is no rule for it. The two keep rules pin the serialized contracts and the bound options; the members: "public" qualifier narrows the options keep to public property names and lets private fields rename. Everything else in the assembly falls to the rename catch-all.

For a single reflection target, a config pattern is a blunt instrument — an attribute on the exact member is more precise and lives next to the code that depends on it:

public sealed class ReportEngine
{
    [Obfuscation(Feature = "renaming", Exclude = true)]   // resolved by name via reflection
    public ReportResult Run(string reportName) { /* ... */ }

    private decimal Accumulate(ReadOnlySpan<Line> lines) { /* ... */ }   // renamed — nothing looks it up
}
Enter fullscreen mode Exit fullscreen mode

The attribute keeps one method's name; Accumulate and the rest of the class rename normally. Reach for attributes on individual, surgical keeps and config patterns on whole namespaces that are a contract by design.

Narrow every entry

The gap between a weak keep-list and a strong one is almost entirely about narrowing. Three moves do most of the work.

Keep the name, still protect the body. Excluding a member from renaming is not excluding it from protection. A kept DTO or options class still gets its method bodies control-flow-flattened, its strings encrypted and its constants masked — you gave up one transform on a name, not the whole treatment on the code. So never hesitate to keep a name you must keep; the cost is only that one name's readability.

Keep members, rename internals. A type bound by serialization needs its public property names kept, not its private backing fields, its constructors or its helper methods. Scope the keep to the members a serializer or binder actually reads and let the rest rename — the members: "public" qualifier above, or an attribute on just the exposed members.

Keep the leaf, not the branch. The commonest over-exclusion is keeping a whole namespace because one type in it is reflected over, or a whole type because one method is. Walk it back to the single name that is resolved as a string. One kept method in a thirty-member class leaves twenty-nine renamed; one kept namespace leaves nothing.

Wherever the platform allows, remove the by-name dependency entirely rather than keep around it: [JsonPropertyName] instead of keeping DTO names, explicit DI registration instead of convention scanning, compiled bindings (x:Bind) instead of string paths. Every dependency you convert from a name to a type is a keep entry you get to delete.

Prove the result

A keep-list is right only if two things are true at once, and you cannot tell which by reading the config. Build the protected assembly and check both in a decompiler. Glass.NET is the free, static-only companion for this — it reads metadata and IL and never runs the binary.

First, confirm nothing that should be hidden survived: browse the internal types, verify they read as a, b, c, that your engine's method names are gone, and that no keep rule accidentally matched more than you meant. Second, confirm nothing that must survive got renamed: check that your public surface, your kept DTO properties and your options names are intact. Over-keeping shows up as readable internals; under-keeping shows up as a renamed contract. The full procedure is in did your obfuscation actually work?.

Then run the test that config review cannot replace: execute your integration suite against the protected build — serialize a DTO and round-trip it, exercise a reflection path, bind a configuration section. Name-resolution breakage is invisible at compile time and obvious the instant the path runs, so a test that crosses one of those seams catches an over-narrowed keep immediately.

Keep it minimal over time

A keep-list is not a set-and-forget file. Every new serialized DTO, every new reflected type, every new options class is a candidate entry — and every refactor that removes a by-name dependency is an entry you can delete. Review it like code: when you add a keep, write next to it why — which serializer, which reflection call, which binding — so the next person can tell whether it is still earning its place. A keep-list whose every line has a reason is one you can keep short, and a short keep-list is the whole game: the smallest possible set of names left in cleartext, with Nebula.NET renaming, encrypting and protecting everything else.

Top comments (0)