DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Obfuscating an ASP.NET Core app: controllers, DI, model binding, and what must stay public

ASP.NET Core is built on reflection in a way that most application frameworks are not. It does not just use reflection to construct things; it uses names — type names, method names, parameter names, property names — as the source of truth for routing, binding, serialization and configuration. That makes it a worst case for an obfuscator that renames aggressively, and a very instructive case for one that renames precisely, because the seam between "what the framework reads by name" and "what nobody outside the process ever sees" is sharp and well-defined once you know where to look.

This post walks the whole seam. For each framework feature we show what it discovers by name, what a naive rename does to it, and what to pin. Then we assemble a nebula.config.json that leaves the observed surface alone and hardens everything behind it — the services, the domain, the licensing check, the integration logic — which is where the code worth protecting actually lives.

MVC: controllers and actions are found by name and routed by name

An MVC controller is discovered because its type is public, non-abstract, and either ends in Controller or carries [Controller]/[ApiController]. Its actions are discovered because they are public instance methods. So far that is only visibility, which renaming preserves. The problem is what happens next: the framework builds routes from the names.

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orders;
    public OrdersController(IOrderService orders) => _orders = orders;

    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> GetById(int id) => ...;

    [HttpGet("[action]")]
    public async Task<ActionResult<PagedResult<OrderDto>>> Search([FromQuery] int page = 1,
                                                                    [FromQuery] string? customer = null) => ...;
}
Enter fullscreen mode Exit fullscreen mode

The route template api/[controller] is resolved at startup by taking the controller type name, stripping the Controller suffix, and substituting: api/Orders. The [action] token substitutes the method name: api/Orders/Search. Both substitutions happen in ApplicationModel construction from TypeInfo.Name and MethodInfo.Name — pure metadata reads. Rename the type to b and the method to c and the URLs become api/b and api/b/c. The app starts clean. Every client gets 404.

The [FromQuery] parameters have the same issue one level down. With no explicit Name, the binder uses the parameter's metadata name: ?page=2&customer=acme. An obfuscator that rewrites parameter names breaks binding silently — the parameter is just left at its default, which for page is 1 and for customer is null, and nobody sees an error.

Three things keep this working, and you should do all of them regardless of obfuscation because they also make your API contract explicit:

  1. Prefer explicit route templates over tokens: [Route("api/orders")], [HttpGet("search")]. A literal string in an attribute is not a name the obfuscator sees as a member; it survives.
  2. Name your bound parameters where the name is part of the contract: [FromQuery(Name = "page")]. Same reasoning.
  3. Keep controllers' type and action names with a rule, for the places you did not do 1 and 2. preservePublicApi covers this for public controllers; a pattern rule covers internal ones.

Nebula's renaming rewrites types and members. It leaves method parameter names in place, so step 2 is defence in depth rather than a necessity — but you want it anyway, because the day a colleague refactors page to pageNumber your public API changes without anyone noticing, and an explicit Name is what stops that.

Model binding and JSON: property names are the wire format

A DTO's property names are its JSON. System.Text.Json builds its contract from PropertyInfo.Name (camel-cased by the default web options), and model binding of form posts and query strings uses the same names. Rename OrderDto.CustomerName to a and the response body is {"a":"Acme"}.

public sealed class OrderDto
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = "";
    public decimal Total { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

You have two correct options. The first is to decorate every serialized property with [JsonPropertyName("customerName")], at which point the member name is irrelevant to the wire format and can be renamed freely. It is also a good habit: it makes the contract explicit and survives any future rename, obfuscator or human. The second is to keep DTO member names via a rule. For a large API the rule is less typing; for a small one the attributes are more robust. Either way, the thing that must not happen is a DTO that is neither attributed nor kept.

Note the asymmetry with the domain model. Your Order entity, PricingPolicy, DiscountRule and the rest are never serialized to the wire — they are mapped into DTOs by code, by property access that the compiler resolved to member tokens, which renaming updates consistently. Those can be renamed, and that is where the valuable logic is.

Minimal APIs: lambdas, closures, and the handler's parameter names

Minimal APIs look like they have no names at all, and that is half true. The handler is a lambda, which the compiler lowers to a method on a closure class — <>c.<<Main>$>b__0_0 or Program.<>c__DisplayClass0_0.<<Main>$>b__0 if it captured something — and nothing about those names matters to routing because the route is the literal string you passed to MapGet.

app.MapGet("/api/orders/search",
    async (int page, string? customer, IOrderService orders, CancellationToken ct)
        => TypedResults.Ok(await orders.SearchAsync(page, customer, ct)));
Enter fullscreen mode Exit fullscreen mode

The framework's RequestDelegateFactory inspects the lambda's MethodInfo and, for each parameter, decides where the value comes from: a route value if the name matches a route template token, a service if the type is registered, the body if it is a complex type, and otherwise the query string by parameter name. So page and customer are bound from ?page= and ?customer= purely because of how the lambda's parameters are named, and orders is bound from DI purely because of its type. The parameter names are the contract; the closure class name is noise.

That puts Minimal APIs in a good place: no type or member name is observed, only parameter names, which Nebula does not rewrite. Make them explicit anyway — [FromQuery(Name = "page")] int page — for the same contract-stability reason as above, and feel free to let the closure classes rename; they already have names no human reads.

Razor views and tag helpers: the model expression reads MemberInfo.Name

Razor views compile to C# classes — in .NET 6+ by default into your main assembly under AspNetCoreGeneratedDocument, each tagged with [RazorCompiledItem(typeof(Views_Orders_Index), "mvc.1.0.view", "/Views/Orders/Index.cshtml")]. The attribute references the type by typeof, so renaming the view class is safe: the token is updated, the identifier string (the path) is what the view engine looks up, and it is a literal.

Tag helpers are where names leak. Take a form:

<form asp-action="Create" asp-controller="Orders" method="post">
    <input asp-for="CustomerName" />
    <span asp-validation-for="CustomerName"></span>
    <input asp-for="Total" />
</form>
Enter fullscreen mode Exit fullscreen mode

Three things are going on, each with a different answer:

  • asp-action="Create" asp-controller="Orders" are literal strings resolved against the route table at render time — the same route table whose entries came from controller and action names. If those names were kept (previous section), this works; if they were renamed, link generation returns an empty action attribute and the form posts to the current URL.
  • asp-for="CustomerName" compiles to ModelExpressionProvider.CreateModelExpression(ViewData, __model => __model.CustomerName). At render time the tag helper walks that expression tree, reads MemberInfo.Name, and emits name="CustomerName" id="CustomerName". If the property is renamed to a, the emitted HTML says name="a" — and because the form posts back to a model binder that also uses the renamed name, binding still works. What breaks is everything that referenced the field from outside: your JavaScript doing document.getElementById("CustomerName"), your CSS, your end-to-end tests, and asp-validation-for client-side rules keyed by the same name.
  • The view model itself is a serialization-like boundary: it is read by name through expressions, and its names reach the browser.

The practical rule: treat view models and page models exactly like DTOs. Keep their member names (or, for Razor Pages, keep the [BindProperty] properties on the PageModel), rename the view classes freely, and never reference a renamed domain type from a view — views should talk to view models, which you already wanted.

SignalR: hub methods are invoked by string

A SignalR client calls connection.invoke("PlaceOrder", dto) and the server resolves "PlaceOrder" against the hub's public methods by name. The client's string is not touched by the obfuscator; the server's method is. Rename it and every invocation fails with Method does not exist. Client-to-server names must be kept — preservePublicApi covers public hub methods — and server-to-client calls (Clients.All.SendAsync("OrderUpdated", ...)) are literal strings that survive on their own. Strongly typed hubs (Hub<IOrderClient>) resolve the client method names from the interface member names, so the interface must be kept too.

Options, configuration, and the things you forgot were bound by name

services.Configure<SmtpOptions>(config.GetSection("Smtp")) populates SmtpOptions by matching configuration keys to property names through ConfigurationBinder, which uses PropertyInfo.Name with a case-insensitive comparison. Rename SmtpOptions.Host to a and the bound value is null. Nothing throws; your emails just stop sending in production. Options classes are the single most commonly forgotten seam in web-app obfuscation because they are internal (so preservePublicApi ignores them) and they look like plain POCOs with nothing attribute-worthy.

The same applies to anything else that reads configuration by convention: [FromKeyedServices("...")] with a nameof, health-check names derived from type names, ILogger<T> category names (these become a.b in your logs — harmless but ugly; the renaming map resolves them), and IHostedService implementations (registered by type — safe).

Pulling it together: the config

Here is a nebula.config.json for a typical layered web app: MyApp.Web (controllers, DTOs, view models, hubs, options, startup), MyApp.Application (services), MyApp.Domain (entities and rules), MyApp.Infrastructure (EF Core, integration clients). The web layer gets selective keeps; the three inner layers are renamed and hardened fully.

{
  "schemaVersion": 1,
  "inputs": [
    "bin/Release/net8.0/publish/MyApp.Web.dll",
    "bin/Release/net8.0/publish/MyApp.Application.dll",
    "bin/Release/net8.0/publish/MyApp.Domain.dll",
    "bin/Release/net8.0/publish/MyApp.Infrastructure.dll"
  ],
  "outputDirectory": "protected",

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

  "renaming": {
    "rules": [
      { "pattern": "MyApp.Web.Controllers.*",   "action": "keep" },
      { "pattern": "MyApp.Web.Hubs.*",          "action": "keep" },
      { "pattern": "MyApp.Web.Contracts.*",     "action": "keep" },
      { "pattern": "MyApp.Web.ViewModels.*",    "action": "keep" },
      { "pattern": "MyApp.Web.Pages.*Model",    "action": "keep" },
      { "pattern": "MyApp.Web.Options.*",       "action": "keep" },
      { "pattern": "AspNetCoreGeneratedDocument.*", "action": "rename" },
      { "pattern": "MyApp.Web.*",               "action": "rename" },
      { "pattern": "MyApp.Application.*",       "action": "rename" },
      { "pattern": "MyApp.Domain.*",            "action": "rename" },
      { "pattern": "MyApp.Infrastructure.*",    "action": "rename" }
    ]
  },

  "exclude": [
    "MyApp.Infrastructure.Persistence.Entities.*"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Reading it top to bottom: preservePublicApi is off, because in a web app "public" is mostly an accident of the project template and we want to be explicit. The first six rules pin exactly the surface the framework observes — controllers, hubs, request/response contracts, view models, page models, options. Generated Razor documents and everything else in the web layer are renamed. The three inner assemblies are renamed wholesale, with string encryption and control-flow obfuscation applied to all of them — that is where the licensing gate, the pricing engine, and the integration secrets live. The EF Core entity namespace is excluded from renaming for the reasons the Entity Framework post walks through (the model builder and migrations snapshot reference members by name); everything else in Infrastructure, including the integration clients, is fair game.

If you prefer attributes over patterns, [JsonPropertyName] on every DTO property and explicit route templates on every action let you drop the Contracts and Controllers keeps entirely, and the config shrinks accordingly.

Verifying the protected build

Unit tests will not catch any of this, because they reference the project and never execute the obfuscated assembly. WebApplicationFactory<Program> is the same: it builds your app in-process from the test project's reference, not from protected/. What catches it is running the published, protected output as a process and driving it over HTTP.

# CI, after obfuscation
dotnet protected/MyApp.Web.dll --urls http://localhost:5199 --environment Smoke &
sleep 3
dotnet test tests/MyApp.Smoke --filter Category=Protected -- BaseUrl=http://localhost:5199
Enter fullscreen mode Exit fullscreen mode

The smoke suite is deliberately small and deliberately boring: one request per route checking status and shape, one JSON round-trip per DTO asserting every property is present under its expected name, one form post per Razor page asserting the model bound, one hub method invocation, and a startup assertion that each options class bound a non-default value from the smoke configuration. Run it against the protected build in the same pipeline that produces the artefact, and the renamed route token or forgotten options class fails the build instead of a customer's deployment. Combined with keeping developer builds clean and obfuscating only on the build server, that is the whole loop.

The takeaway

ASP.NET Core reads names from a short, well-defined list of places: route tokens, bound parameter names, serialized property names, hub method names, Razor model expressions, and configuration-bound options. Every one of them is either a literal you can make explicit or a member you keep with a rule, and none of them are where your valuable code is. Pin that surface, make the contract explicit with attributes where you can, and let the obfuscator rename, encrypt and flatten the services, the domain, the license check and the integrations behind it. Then run the protected build, not the source, through a smoke suite, because the framework will not tell you what it failed to find — it will just return 404.

Top comments (0)