Skip to content
← Tous les articles
· Delta1 Labs ObfuscationNebula

Obfusquer une application SignalR sans casser les noms de méthode de hub que les clients appellent par chaîne

SignalR dispatche chaque message temps réel en faisant correspondre une chaîne à une méthode. Un client appelle `connection.InvokeAsync("SendMessage", ...)` et enregistre `hubConnection.On("ReceiveMessage", ...)`, et le serveur résout ces chaînes vers des méthodes de hub par leur nom à l'exécution. Renommez une méthode de hub publique ou un membre d'une interface de client fortement typée et le dispatch échoue silencieusement : la connexion reste ouverte, mais le message n'arrive jamais. Cet article décortique comment le dispatcher de SignalR fait correspondre la chaîne cible de l'invocation à votre méthode de hub, pourquoi `[HubMethodName]` et `Hub<T>` changent quels noms sont critiques, comment construire une liste de conservation dans nebula.config.json qui épingle exactement la surface de méthodes visible sur le fil tout en renommant les corps du hub, les services internes et les détails internes des DTO, et comment confirmer dans Glass.NET que seuls les noms prévus ont survécu.

Un assembly SignalR ressemble à du C# ordinaire, mais une poignée de ses noms ne sont pas vraiment des noms C# : ce sont des chaînes sur un fil, et l’obfuscateur doit les traiter comme l’opposé de tout ce qui les entoure.

Le contrat temps réel de SignalR est entièrement bâti à partir de chaînes. Un client invoque une méthode du serveur par son nom — connection.InvokeAsync("SendMessage", roomId, text) — et enregistre un gestionnaire pour un message serveur-vers-client par son nom — hubConnection.On("ReceiveMessage", ...). Côté serveur, votre hub expose une méthode publique SendMessage et pousse vers les clients avec Clients.All.SendAsync("ReceiveMessage", ...). Rien dans cette boucle n’est vérifié par le compilateur. Le lien entre "SendMessage" la chaîne et SendMessage la méthode se fait à l’exécution, par le nom, à l’intérieur du dispatcher de SignalR. Renommez la méthode et la chaîne ne se résout plus : le socket reste ouvert, la poignée de main réussit quand même, et le message n’arrive tout simplement jamais.

C’est là tout le problème. Une passe de renommage aveugle aplatit SendMessage en a et votre application de chat cesse de livrer les messages sans erreur à la compilation ni plantage au démarrage. La solution n’est pas d’arrêter d’obfusquer ; c’est de savoir exactement quels noms sont sur le fil et d’épingler précisément ceux-là, en laissant tout le reste se renommer.

CLIENT — appelle par chaîneInvokeAsync("SendMessage", ...)On("ReceiveMessage", handler)la chaîne est le contratDISPATCHER SIGNALRréfléchit le hub au démarrage,crée map nom → méthodeassocie la chaîne cibleà la méthode PAR NOMaucune vérif du compilateurCONSERVER CES NOMSméthode hub : SendMessageiface client : ReceiveMessagevisible sur le fil → épinglerLIBRE DE RENOMMERcorps des méthodes de hubIMessageStore, servicesmembres internes des DTOassistants privés, DIjamais lus comme chaîne

Comment SignalR résout réellement un nom de méthode

Au démarrage de votre application, SignalR inspecte chaque type de hub et construit un dictionnaire de ses méthodes invocables indexé par nom. Conceptuellement, le dispatcher par défaut fait l’équivalent de ceci au démarrage :

// Simplifié depuis DefaultHubDispatcher<THub> de SignalR.
foreach (var method in typeof(ChatHub).GetMethods(/* public instance */))
{
    // La clé du dictionnaire est le NOM de la méthode — une chaîne.
    var name = method.GetCustomAttribute<HubMethodNameAttribute>()?.Name
               ?? method.Name;
    _methods[name] = new HubMethodDescriptor(method, ...);
}

Chaque invocation entrante porte une chaîne target. Le dispatch est une recherche dans le dictionnaire sur cette chaîne :

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

Le nom de la méthode de hub est donc la clé, et la clé est une chaîne que le compilateur ne valide jamais. C’est la même catégorie de nom critique que l’on retrouve partout où un framework franchit une frontière par réflexion — et cela signifie que renommer la méthode change la clé tandis que le client continue d’envoyer l’ancienne chaîne.

Voici le hub à partir duquel la recherche est construite :

public class ChatHub : Hub
{
    private readonly IMessageStore _store;          // interne — se renomme librement
    public ChatHub(IMessageStore store) => _store = store;

    // Méthode de hub PUBLIQUE : "SendMessage" est le nom sur le fil que les clients invoquent.
    public async Task SendMessage(string roomId, string text)
    {
        var saved = await _store.AppendAsync(roomId, text, Context.ConnectionId);
        // "ReceiveMessage" est le nom sur le fil que les clients écoutent.
        await Clients.Group(roomId).SendAsync("ReceiveMessage", saved.Author, saved.Text);
    }
}

Le client JavaScript qui l’accompagne ne référence jamais un identifiant C# — seulement les deux chaînes :

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);

Renommez le SendMessage C# en a et connection.invoke("SendMessage", ...) cible désormais une clé qui n’existe plus ; SignalR répond Unknown hub method 'SendMessage'. Ne renommez rien mais livrez l’envoi de "ReceiveMessage" sous un nom renommé, et le .on("ReceiveMessage") du client ne se déclenche jamais. Les deux échecs ne surviennent qu’à l’exécution, contre une connexion vivante.

Découpler le nom sur le fil avec [HubMethodName]

Conserver l’identifiant C# est une façon de protéger le nom, mais cela couple à jamais la nomenclature de votre code à votre contrat sur le fil. La solution plus propre consiste à déclarer le nom sur le fil explicitement pour que l’identifiant redevienne libre :

public class ChatHub : Hub
{
    [HubMethodName("SendMessage")]           // le nom sur le fil est fixé ici…
    public async Task DispatchChatMessage(   // …donc cet identifiant peut être renommé en 'a'
        string roomId, string text)
    {
        // ...
    }
}

Désormais le dispatcher enregistre la méthode sous "SendMessage" quel que soit le nom de la méthode C#, de sorte que l’identifiant DispatchChatMessage sort entièrement du contrat sur le fil et peut être renommé. Cela vaut la peine même si vous n’obfusquez jamais : cela empêche votre API temps réel publique de dépendre silencieusement du nom de vos méthodes internes.

Les hubs fortement typés déplacent les noms vers une interface

Avec Hub<T>, les appels serveur-vers-client cessent d’être des chaînes libres dans votre code et deviennent des appels de méthode sur une interface — mais SignalR transforme toujours ces noms de méthode de l’interface en chaînes sur le fil :

public interface IChatClient
{
    // Le NOM de chaque membre devient le nom sur le fil envoyé aux clients.
    Task ReceiveMessage(string author, string text);
    Task UserJoined(string connectionId);
}

public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string roomId, string text)
    {
        // Fortement typé — mais ceci émet quand même le nom sur le fil "ReceiveMessage".
        await Clients.Group(roomId).ReceiveMessage("system", text);
    }
}

SignalR construit un proxy sur IChatClient et, quand vous appelez ReceiveMessage(...), envoie une invocation dont la chaîne cible est "ReceiveMessage" — le nom du membre. Le client enregistre toujours hubConnection.On("ReceiveMessage", ...). Les noms des membres de l’interface sont donc sur le fil exactement comme les méthodes de hub publiques, et renommer IChatClient.ReceiveMessage en a fait émettre "a" par le serveur alors que chaque client écoute "ReceiveMessage". L’implémentation du hub, en revanche, est interne et se renomme sans problème.

La liste de conservation dans nebula.config.json

Nebula ne devine pas quelles chaînes sont sur le fil. Vous déclarez la surface visible sur le fil — les méthodes de hub publiques et l’interface de client fortement typée — comme règles keep, et vous laissez un joker renommer tout le reste :

{
  "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" }
    ]
  }
}

La première règle épingle les types de hub et les noms de leurs méthodes publiques — SendMessage survit —, tandis que le champ privé _store, le câblage du constructeur et tout assistant privé à l’intérieur du hub se renomment quand même parce que members: "public" restreint la conservation à la seule surface publique. La deuxième règle épingle les interfaces de client fortement typées (ici reconnues par la convention I…Client) et les noms de leurs membres, afin que ReceiveMessage et UserJoined restent intacts. Le joker final rename balaie tout le reste de l’assembly — votre IMessageStore et son implémentation, les détails internes des DTO, les intercepteurs, les enregistrements de DI — vers le renommage complet.

Si vous avez adopté [HubMethodName] sur chaque méthode de hub et des noms explicites sur l’interface de client, vous pouvez resserrer même les deux premières règles, car les noms sur le fil ne vivent plus du tout dans les identifiants : seules les valeurs des attributs comptent, et ce sont des littéraux de chaîne que Nebula conserve. Pour une unique méthode de hub qui est l’exception plutôt que la règle, un attribut sur le membre exact est plus précis qu’un motif de configuration et vit à côté du code :

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

Exécutez la protection dans le cadre de la build de publication, pas sur les machines des développeurs :

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

Ou invoquez le CLI directement contre une build publiée :

nebula protect --config nebula.config.json

Vérifier la surface avec Glass.NET

Une liste de conservation est une affirmation sur les noms qui ont survécu, et il vaut mieux vérifier l’affirmation que s’y fier. Ouvrez l’assembly protégé dans Glass.NET, le décompilateur .NET gratuit de Delta1 Labs, et observez deux choses.

D’abord, la surface conservée. Votre ChatHub devrait toujours afficher des méthodes publiques nommées SendMessage (ou les noms sur le fil que vous avez épinglés), et l’interface IChatClient devrait toujours lister ReceiveMessage et UserJoined. Ce sont les chaînes que vos clients envoient et écoutent ; si l’une d’elles est ressortie renommée en a, votre motif de conservation l’a manquée et le dispatch temps réel échouera en production.

Ensuite, tout ce qui se trouve derrière la surface. Le champ privé _store du hub, le corps de SendMessage, l’implémentation de IMessageStore et les détails internes de vos DTO devraient tous afficher des identifiants renommés et — avec encryptStrings activé — des constantes de chaîne opaques. C’est la protection que vous recherchiez, intacte exactement sur le code qui vaut la peine d’être protégé.

La règle sous-jacente à tout cela est celle qu’obéit tout framework piloté par réflexion : un nom est critique exactement quand quelque chose en dehors de la propre résolution de jetons du CLR le lit comme une chaîne. Dans SignalR, cet ensemble est petit et connaissable — les noms des méthodes de hub publiques et les membres de l’interface de client fortement typée — et une fois que vous avez épinglé précisément ceux-là, Nebula renomme librement les corps du hub, les services et les DTO, protégeant votre propre application temps réel livrée sans jamais déplacer un nom dont dépend un client.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.