Skip to content
← All posts
· Delta1 Labs Nebula.NETObfuscation.NETSource Generators

Obfuscating source-generated code: why System.Text.Json contexts, [GeneratedRegex] and [LoggerMessage] survive renaming — and the one name they leak

Source generators run inside the compiler and emit ordinary C# into your assembly, so the IL they produce is indistinguishable from code you typed. The useful consequence for obfuscation is that generators resolve names at compile time: a System.Text.Json context captures "Name" as a string literal and reaches the property through a delegate, the configuration binder switches on literal keys, and a GeneratedRegex is pure type identity. Rename everything and the JSON, the bound options and the regex keep working — unlike their reflection-based equivalents. What generators do leave behind is literals: the regex pattern, the log message template, and a nameof() that bakes your original method name into an EventId. Here is what each generator emits, what Nebula.NET renames safely, and what to encrypt or exclude.

A source generator is a compiler plugin that reads your code and emits more code into the same compilation. By the time the C# compiler finishes, the generated files are just part of your assembly — same metadata tables, same IL, no marker in the output that distinguishes a method you wrote from one the generator wrote. Which raises the obvious question for an obfuscator: when Nebula renames your types and members, what happens to code that was generated around them?

The answer is better than you’d expect, with one twist. Generators resolve names at compile time, which is exactly the property that makes code rename-safe. But the way they do it is by writing your names down as string literals, and those literals survive renaming verbatim — occasionally telling an attacker what a method used to be called.

Runtime lookup versus compile-time capture

The whole reason obfuscation breaks things is runtime name lookup. Type.GetProperty("Name"), JsonSerializer.Serialize(obj) without a context, IConfiguration.Bind(options) — each walks the metadata at runtime, finds a member by its string, and stops finding it once the member is renamed. That’s why autoDetect preserves members carrying serialization attributes and the docs tell you to exclude your settings classes.

A generator does the lookup at compile time and emits two things instead: a token reference to the member (a direct call/ldfld, or a delegate) and, where a name is needed at runtime, a literal copied from your source. Renaming rewrites every token reference and leaves literals alone.

Reflection: lookup at runtimeGetProperty("Name") → metadataCustomer.Name renamed to a"Name" not found → breaksGenerator: capture at compile timePropertyName = "Name" (literal)Getter = o => o.Name (token)Customer.Name renamed to aGetter → o.a, wire name "Name" → works

So the rule for generated code is the inverse of the rule for reflection: the member is safe to rename; the literal is what you have to think about. Let’s look at what four common generators actually emit.

System.Text.Json: the context captures the name and the accessor

Take a DTO with no serialization attributes at all and a source-generated context:

public sealed class Customer
{
    public string Name  { get; set; } = "";
    public int    Tier  { get; set; }
}

[JsonSerializable(typeof(Customer))]
internal partial class AppJsonContext : JsonSerializerContext { }

Enable generated-file output once — <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in the project — and read what the generator produced for Customer. The metadata initializer looks like this (trimmed):

private static JsonPropertyInfo[] CustomerPropInit(JsonSerializerOptions options)
{
    var properties = new JsonPropertyInfo[2];

    var info0 = new JsonPropertyInfoValues<string>
    {
        IsProperty    = true,
        DeclaringType = typeof(global::Customer),
        Getter        = static obj => ((global::Customer)obj).Name,
        Setter        = static (obj, value) => ((global::Customer)obj).Name = value!,
        PropertyName  = "Name",          // captured at generation time
        JsonPropertyName = null,
    };
    properties[0] = JsonMetadataServices.CreatePropertyInfo<string>(options, info0);
    // ... Tier ...
    return properties;
}

Two things to notice. The getter and setter are lambdas that touch the property directly — in IL that’s ldftn on a compiler-generated method whose body is callvirt Customer::get_Name(). The wire name is a ldstr "Name". After Nebula renames Customer to a and Name to b, the lambda body becomes callvirt a::get_b() — rewritten by token, as all call sites are — while the ldstr still says "Name". Serialize a customer and you get {"Name":"…","Tier":3} exactly as before.

In the fast-path serialization mode the generator does the same thing with a pre-encoded name — private static readonly JsonEncodedText PropName_Name = JsonEncodedText.Encode("Name"); — and a hand-written writer.WriteString(PropName_Name, value.Name). Same shape: literal plus direct member access.

That is strictly better than reflection, and it means the DTO needs no [JsonPropertyName] safety net and no exclude entry. The property can be renamed and the contract holds, because the contract was captured when the generator ran.

The one way this still breaks is a code path that doesn’t use the context. JsonSerializer.Serialize(customer) with no options, or a resolver chain that ends in DefaultJsonTypeInfoResolver, resolves names from metadata at runtime and will produce {"b":"…"} after renaming. You don’t want to discover that in the obfuscated build. Turn the reflection path off at the project level:

<PropertyGroup>
  <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>

Now any call that would have silently fallen back to reflection throws in development, and every serialization path that reaches production is one the generator captured.

[GeneratedRegex]: pure type identity

public static partial class Parsers
{
    [GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$")]
    private static partial Regex IsoDate();
}

The generator fills in IsoDate() to return a cached singleton of a generated class — a sealed type deriving from Regex whose constructor sets pattern, roptions and factory, with a nested RunnerFactory that creates a nested Runner implementing Scan, TryFindNextPossibleStartingPosition and TryMatchAtCurrentPosition. Nothing in there resolves anything by name. The runner is reached through the factory field the base class already expects; the user’s call is call Parsers::IsoDate() → ldsfld of the instance. Rename the method, the generated class, the factory and the runner, and the regex still matches.

What’s left is the pattern literal, assigned in the generated constructor (base.pattern = "^\d{4}-\d{2}-\d{2}$"). That’s an ordinary string in IL and encryptStrings hides it at rest like any other.

The real consideration here is performance, not correctness. TryMatchAtCurrentPosition is the compiled matching loop — exactly the kind of tight, branchy code that control-flow flattening makes measurably slower. If the regex runs on a hot path, keep flattening away from it. The generated types carry compiler-mangled names, so find the exact method names with nebula inspect --methods and add them to controlFlowExclude, or — simpler — scope flattening to the methods you actually need protected with controlFlowInclude.

[LoggerMessage]: the generator writes nameof() for you

public static partial class Log
{
    [LoggerMessage(EventId = 12, Level = LogLevel.Warning,
                   Message = "Order {OrderId} rejected: {Reason}")]
    public static partial void OrderRejected(ILogger logger, int orderId, string reason);
}

The generated half is a cached LoggerMessage.Define callback:

private static readonly Action<ILogger, int, string, Exception?> __OrderRejectedCallback =
    LoggerMessage.Define<int, string>(
        LogLevel.Warning,
        new EventId(12, nameof(OrderRejected)),            // ← your method name, as a string
        "Order {OrderId} rejected: {Reason}",
        new LogDefineOptions { SkipEnabledCheck = true });

public static partial void OrderRejected(ILogger logger, int orderId, string reason)
{
    if (logger.IsEnabled(LogLevel.Warning))
        __OrderRejectedCallback(logger, orderId, reason, null);
}

That nameof(OrderRejected) is evaluated by the C# compiler. In the IL it is ldstr "OrderRejected" — there is no reference left to the method, just the characters. Nebula renames Log.OrderRejected to a.b; the EventId still carries the name "OrderRejected". Two consequences:

  • Your logging keeps working as designed. Dashboards and alerts keyed on the event name see the same name before and after obfuscation. Good.
  • The binary now contains a map entry. A decompiler shows a method b whose static initializer says "OrderRejected". For a class full of log methods, that’s a readable index of what every renamed method does. This is the same mechanism described in when nameof leaks your renamed members, produced here by a generator instead of your own code.

encryptStrings is the fix, and it’s the right one: the literal is encrypted in the assembly and decrypted when the static initializer runs, so your logs still say OrderRejected while the file on disk doesn’t. The message template gets the same treatment, which also hides the {OrderId}/{Reason} parameter names — small, but they describe the method’s arguments.

The configuration binding generator: literal keys instead of a metadata walk

The reflection-based ConfigurationBinder is on every “preserve this” list for a reason: Bind matches configuration keys to property names by reflecting over the options type at runtime. .NET 8 added a source-generated binder:

<PropertyGroup>
  <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>

With it on, builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp")) is intercepted and routed to generated code of this shape:

public static void BindCore(IConfiguration configuration, ref SmtpOptions instance, BinderOptions? binderOptions)
{
    if (configuration["Host"] is string value0)
        instance.Host = value0;                                  // direct assignment, literal key

    if (configuration["Port"] is string value1)
        instance.Port = ParseInt(value1, configuration.GetSection("Port").Path);
}

"Host" and "Port" are ldstr; instance.Host = … is callvirt SmtpOptions::set_Host. Rename SmtpOptions.Host to a.c and the generated binder still reads appsettings.json’s Smtp:Host and still calls the renamed setter. The exclude entry for your settings classes that the reflection binder forced on you is no longer needed for binding.

Check what else reads those classes by name before you drop the exclusion: ValidateDataAnnotations reports member names in its error messages (cosmetic), and any leftover Bind call compiled without the generator still uses reflection. The generated-files folder tells you which calls were intercepted.

A config that fits generated code

Putting it together for a typical service that uses all four generators:

{
  "inputs": ["out/Acme.Service.dll"],
  "outputDirectory": "out/obf",
  "preservePublicApi": false,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "controlFlowIntensity": "normal",
  "controlFlowExclude": [
    "Acme.Service.Parsers.<RegexGenerator_g>F…__IsoDate_0.Runner.TryMatchAtCurrentPosition"
  ]
}

encryptStrings is the line doing the most work here: it covers the regex pattern, the log templates and the nameof literals in one setting. controlFlowExclude keeps flattening off the regex runner’s inner loop (paste the exact mangled name from nebula inspect --methods). Nothing in the config preserves the DTO, the options class or the log methods, because nothing needs to.

The checklist for any generator you haven’t met yet is the same one this post applied four times. Emit the generated files, open them, and ask two questions of each member access: is it a token (a direct call, a typeof, a delegate) or a string (GetProperty("…"), Type.GetType("…"))? Tokens are rename-safe. Then look at every literal and ask whether it’s a wire contract you need unchanged (fine — it is unchanged) or a copy of a name you were hoping to hide (encrypt it). Generators that do their work at compile time are, almost by construction, the easiest code in your assembly to obfuscate. See Keeping your app working for the reflection-side rules these generators let you relax.

Download Nebula.NET free and run your serialization tests against the obfuscated build.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.