Obfuscating a SignalR app without breaking the hub method names clients call by string

2026-11-13来源 dev.toEnglish

A SignalR assembly looks like ordinary C#, but a handful of its names are not really C# names at all — they are strings on a wire, and the obfuscator has to treat them as the opposite of everything around them.

The real-time contract in SignalR is built entirely out of strings. A client invokes a server method by name — connection.InvokeAsync("SendMessage", roomId, text) — and registers a handler for a server-to-client message by name — hubConnection.On("ReceiveMessage", ...). On the server, your hub exposes a public method SendMessage and pushes to clients with Clients.All.SendAsync("ReceiveMessage", ...). Nothing in that loop is checked by the compiler. The connection between "SendMessage" the string and SendMessage the method is made at runtime, by name, inside SignalR's dispatcher. Rename the method and the string no longer resolves — the socket stays open, the handshake still succeeds, and the message simply never arrives.

That is the whole problem. A blind rename pass flattens SendMessage to a and your chat app stops delivering messages with no error at build time and no crash at startup. The fix is not to stop obfuscating; it is to know exactly which names are on the wire and pin precisely those, letting everything behind them rename.

How SignalR actually resolves a method name

When your app starts, SignalR inspects each hub type and builds a dictionary of its invokable methods keyed by name. Conceptually the default dispatcher does the equivalent of this at startup:

// Simplified from SignalR's DefaultHubDispatcher<THub>. foreach (var method in typeof(ChatHub).GetMethods(/* public instance */)) { // The dictionary key is the method NAME — a string. var name = method.GetCustomAttribute<HubMethodNameAttribute>()?.Name ?? method.Name; _methods[name] = new HubMethodDescriptor(method, ...); } 

Every incoming invocation carries a target string. Dispatch is a dictionary lookup on that string:

if (!_methods.TryGetValue(invocation.Target, out var descriptor)) throw new HubException($"Unknown hub method '{invocation.Target}'."); 

So the hub method name is the key, and the key is a string the compiler never validates. This is the same category of load-bearing name you find anywhere a framework reaches across a boundary by reflection — and it means renaming the method changes the key while the client keeps sending the old string.

Here is the hub the lookup is built from:

public class ChatHub : Hub { private readonly IMessageStore _store; // internal — renames freely public ChatHub(IMessageStore store) => _store = store; // PUBLIC hub method: "SendMessage" is the wire name clients invoke. public async Task SendMessage(string roomId, string text) { var saved = await _store.AppendAsync(roomId, text, Context.ConnectionId); // "ReceiveMessage" is the wire name clients listen for. await Clients.Group(roomId).SendAsync("ReceiveMessage", saved.Author, saved.Text); } } 

The JavaScript client that pairs with it never references a C# identifier — only the two strings:

const connection = new signalR.HubConnectionBuilder().withUrl("/chat").build(); connection.on("ReceiveMessage", (author, text) => appendLine(author, text)); await connection.start(); await connection.invoke("SendMessage", roomId, text); 

Rename the C# SendMessage to a and connection.invoke("SendMessage", ...) now targets a key that no longer exists; SignalR answers with Unknown hub method 'SendMessage'. Rename nothing but ship the "ReceiveMessage" send under a renamed-away name and the client's .on("ReceiveMessage") never fires. Both failures happen only at runtime, against a live connection.

Decoupling the wire name with [HubMethodName]

Keeping the C# identifier is one way to protect the name, but it couples your code's naming to your wire contract forever. The cleaner fix is to state the wire name explicitly so the identifier is free again:

public class ChatHub : Hub { [HubMethodName("SendMessage")] // wire name is fixed here… public async Task DispatchChatMessage( // …so this identifier can rename to 'a' string roomId, string text) { // ... } } 

Now the dispatcher registers the method under "SendMessage" regardless of what the C# method is called, so the identifier DispatchChatMessage drops out of the wire contract entirely and can be renamed. This is worth doing even if you never obfuscate: it stops your public real-time API from silently depending on your internal method names.

Strongly-typed hubs move the names onto an interface

With Hub<T> the server-to-client calls stop being loose strings in your code and become method calls on an interface — but SignalR still turns those interface method names into wire strings:

public interface IChatClient { // Each member NAME becomes the wire name sent to clients. Task ReceiveMessage(string author, string text); Task UserJoined(string connectionId); } public class ChatHub : Hub<IChatClient> { public async Task SendMessage(string roomId, string text) { // Strongly typed — but this still emits the wire name "ReceiveMessage". await Clients.Group(roomId).ReceiveMessage("system", text); } } 

SignalR builds a proxy over IChatClient and, when you call ReceiveMessage(...), sends an invocation whose target string is "ReceiveMessage" — the member name. The client still registers hubConnection.On("ReceiveMessage", ...). So the interface member names are on the wire just like public hub methods, and renaming IChatClient.ReceiveMessage to a makes the server emit "a" while every client listens for "ReceiveMessage". The implementation of the hub, by contrast, is internal and renames cleanly.

The keep-list in nebula.config.json

Nebula does not guess which strings are on the wire. You declare the wire-visible surface — the public hub methods and the strongly-typed client interface — as keep rules, and let a catch-all rename everything else:

{ "schemaVersion": 1, "inputs": [ "bin/Release/net8.0/Chat.Server.dll" ], "outputDirectory": "protected", "encryptStrings": true, "controlFlowObfuscation": true, "renaming": { "rules": [ { "pattern": "Chat.Server.Hubs.*", "action": "keep", "members": "public" }, { "pattern": "Chat.Server.Contracts.I*Client", "action": "keep", "members": "public" }, { "pattern": "Chat.Server.*", "action": "rename" } ] } } 

The first rule pins the hub types and their public method names — SendMessage survives, while the private _store field, constructor plumbing and any private helpers inside the hub still rename because members: "public" narrows the keep to the public surface only. The second rule pins the strongly-typed client interfaces (here matched by the I…Client convention) and their member names so ReceiveMessage and UserJoined stay intact. The final rename catch-all sweeps everything else in the assembly — your IMessageStore and its implementation, DTO internals, interceptors, DI registrations — into full renaming.

If you adopted [HubMethodName] on every hub method and explicit names on the client interface, you can tighten even the first two rules, because the wire names no longer live in the identifiers at all — only the attribute values matter, and those are string literals Nebula preserves. For a single hub method that is the exception rather than the rule, an attribute on the exact member is more precise than a config pattern and lives next to the code:

[Obfuscation(Feature = "renaming", Exclude = true, ApplyToMembers = false)] public async Task SendMessage(string roomId, string text) { /* ... */ } 

Run the protection as part of the release build, not on developer machines:

<Target Name="NebulaProtect" AfterTargets="Publish" Condition="'$(CI)' == 'true'"> <Exec Command="nebula protect --config nebula.config.json" /> </Target> 

Or invoke the CLI directly against a published build:

nebula protect --config nebula.config.json 

Verifying the surface with Glass.NET

A keep-list is a claim about which names survived, and you should check the claim rather than trust it. Open the protected assembly in Glass.NET, Delta1 Labs' free .NET decompiler, and look at two things.

First, the kept surface. Your ChatHub should still show public methods named SendMessage (or whatever wire names you pinned), and the IChatClient interface should still list ReceiveMessage and UserJoined. These are the strings your clients send and listen for; if any of them came out renamed to a, your keep-list pattern missed it and real-time dispatch will fail in production.

Second, everything behind the surface. The hub's private _store field, the body of SendMessage, the IMessageStore implementation and your DTO internals should all show renamed identifiers and — with encryptStrings on — opaque string constants. That is the protection you came for, intact on exactly the code worth protecting.

The rule underneath all of this is the one every reflection-driven framework obeys: a name is load-bearing exactly when something outside the CLR's own token resolution reads it as a string. In SignalR that set is small and knowable — the public hub method names and the strongly-typed client interface members — and once you have pinned precisely those, Nebula renames the hub bodies, the services and the DTOs freely, protecting your own shipped real-time application without ever moving a name a client depends on.

原文:https://www.a8q9.cn/en/article/obfuscating-a-signalr-app-without-breaking-the-hub-method-names-clients-call-by--98bf1a.html

相关文章