Skip to content
← Todas las publicaciones
· Delta1 Labs ObfuscationNebula

Ofuscar una app SignalR sin romper los nombres de método de hub que los clientes invocan por cadena

SignalR despacha cada mensaje en tiempo real haciendo coincidir una cadena con un método. Un cliente llama a `connection.InvokeAsync("SendMessage", ...)` y registra `hubConnection.On("ReceiveMessage", ...)`, y el servidor resuelve esas cadenas a métodos de hub por nombre en tiempo de ejecución. Si renombras un método público de hub o un miembro de una interfaz de cliente fuertemente tipada, el despacho falla en silencio: la conexión sigue viva, pero el mensaje nunca llega. Este artículo analiza cómo el despachador de SignalR asigna la cadena objetivo de la invocación a tu método de hub, por qué `[HubMethodName]` y `Hub<T>` cambian qué nombres son críticos, cómo construir una lista de conservación en nebula.config.json que fije exactamente la superficie de métodos visible en el cable mientras renombra los cuerpos del hub, los servicios internos y los detalles internos de los DTO, y cómo confirmar en Glass.NET que solo sobrevivieron los nombres previstos.

Un ensamblado de SignalR parece C# corriente, pero un puñado de sus nombres no son en realidad nombres de C#: son cadenas en un cable, y el ofuscador debe tratarlos como lo opuesto de todo lo que los rodea.

El contrato en tiempo real de SignalR está construido por completo a base de cadenas. Un cliente invoca un método del servidor por nombre —connection.InvokeAsync("SendMessage", roomId, text)— y registra un manejador para un mensaje servidor-a-cliente por nombre —hubConnection.On("ReceiveMessage", ...)—. En el servidor, tu hub expone un método público SendMessage y empuja a los clientes con Clients.All.SendAsync("ReceiveMessage", ...). Nada en ese bucle lo comprueba el compilador. La conexión entre "SendMessage" la cadena y SendMessage el método se hace en tiempo de ejecución, por nombre, dentro del despachador de SignalR. Renombra el método y la cadena deja de resolverse: el socket sigue abierto, el handshake igual tiene éxito, y el mensaje sencillamente nunca llega.

Ese es todo el problema. Una pasada de renombrado a ciegas aplana SendMessage a a y tu app de chat deja de entregar mensajes sin error al compilar y sin fallo al arrancar. La solución no es dejar de ofuscar; es saber exactamente qué nombres están en el cable y fijar precisamente esos, dejando que todo lo demás se renombre.

CLIENTE — llama por cadenaInvokeAsync("SendMessage", ...)On("ReceiveMessage", handler)la cadena es el contratoDESPACHADOR SIGNALRrefleja el hub al arrancar,crea mapa nombre → métodoasocia la cadena objetivoal método POR NOMBREsin comprobación del compiladorCONSERVAR ESTOS NOMBRESmétodo hub: SendMessageiface cliente: ReceiveMessagevisible en cable → fijarLIBRE DE RENOMBRARcuerpos de métodos de hubIMessageStore, serviciosmiembros internos de DTOayudantes privados, DInunca se leen como cadena

Cómo resuelve SignalR un nombre de método en realidad

Cuando tu app arranca, SignalR inspecciona cada tipo de hub y construye un diccionario de sus métodos invocables indexado por nombre. Conceptualmente, el despachador por defecto hace el equivalente de esto al arrancar:

// Simplificado de DefaultHubDispatcher<THub> de SignalR.
foreach (var method in typeof(ChatHub).GetMethods(/* public instance */))
{
    // La clave del diccionario es el NOMBRE del método — una cadena.
    var name = method.GetCustomAttribute<HubMethodNameAttribute>()?.Name
               ?? method.Name;
    _methods[name] = new HubMethodDescriptor(method, ...);
}

Cada invocación entrante lleva una cadena target. El despacho es una búsqueda en el diccionario sobre esa cadena:

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

Así que el nombre del método de hub es la clave, y la clave es una cadena que el compilador nunca valida. Es la misma categoría de nombre crítico que encuentras en cualquier punto donde un framework cruza una frontera por reflexión, y significa que renombrar el método cambia la clave mientras el cliente sigue enviando la cadena antigua.

Aquí está el hub a partir del cual se construye la búsqueda:

public class ChatHub : Hub
{
    private readonly IMessageStore _store;          // interno — se renombra libremente
    public ChatHub(IMessageStore store) => _store = store;

    // Método PÚBLICO de hub: "SendMessage" es el nombre de cable que invocan los clientes.
    public async Task SendMessage(string roomId, string text)
    {
        var saved = await _store.AppendAsync(roomId, text, Context.ConnectionId);
        // "ReceiveMessage" es el nombre de cable que los clientes escuchan.
        await Clients.Group(roomId).SendAsync("ReceiveMessage", saved.Author, saved.Text);
    }
}

El cliente JavaScript que lo acompaña nunca referencia un identificador C#: solo las dos cadenas:

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

Renombra el SendMessage de C# a a y connection.invoke("SendMessage", ...) ahora apunta a una clave que ya no existe; SignalR responde con Unknown hub method 'SendMessage'. No renombres nada pero publica el envío de "ReceiveMessage" bajo un nombre que se renombró, y el .on("ReceiveMessage") del cliente nunca se dispara. Ambos fallos ocurren solo en tiempo de ejecución, contra una conexión viva.

Desacoplar el nombre del cable con [HubMethodName]

Conservar el identificador C# es una forma de proteger el nombre, pero acopla para siempre la nomenclatura de tu código a tu contrato de cable. La solución más limpia es declarar el nombre del cable explícitamente para que el identificador vuelva a quedar libre:

public class ChatHub : Hub
{
    [HubMethodName("SendMessage")]           // el nombre de cable se fija aquí…
    public async Task DispatchChatMessage(   // …así este identificador puede renombrarse a 'a'
        string roomId, string text)
    {
        // ...
    }
}

Ahora el despachador registra el método bajo "SendMessage" sin importar cómo se llame el método C#, de modo que el identificador DispatchChatMessage sale por completo del contrato de cable y puede renombrarse. Vale la pena hacerlo aunque nunca ofusques: evita que tu API pública en tiempo real dependa en silencio de los nombres de tus métodos internos.

Los hubs fuertemente tipados mueven los nombres a una interfaz

Con Hub<T> las llamadas servidor-a-cliente dejan de ser cadenas sueltas en tu código y pasan a ser llamadas a métodos de una interfaz, pero SignalR sigue convirtiendo esos nombres de método de la interfaz en cadenas de cable:

public interface IChatClient
{
    // El NOMBRE de cada miembro pasa a ser el nombre de cable enviado a los clientes.
    Task ReceiveMessage(string author, string text);
    Task UserJoined(string connectionId);
}

public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string roomId, string text)
    {
        // Fuertemente tipado — pero esto igual emite el nombre de cable "ReceiveMessage".
        await Clients.Group(roomId).ReceiveMessage("system", text);
    }
}

SignalR construye un proxy sobre IChatClient y, cuando llamas a ReceiveMessage(...), envía una invocación cuya cadena objetivo es "ReceiveMessage": el nombre del miembro. El cliente sigue registrando hubConnection.On("ReceiveMessage", ...). Así que los nombres de los miembros de la interfaz están en el cable igual que los métodos públicos de hub, y renombrar IChatClient.ReceiveMessage a a hace que el servidor emita "a" mientras cada cliente escucha "ReceiveMessage". La implementación del hub, en cambio, es interna y se renombra sin problemas.

La lista de conservación en nebula.config.json

Nebula no adivina qué cadenas están en el cable. Tú declaras la superficie visible en el cable —los métodos públicos de hub y la interfaz de cliente fuertemente tipada— como reglas keep, y dejas que un comodín renombre todo lo demás:

{
  "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 primera regla fija los tipos de hub y los nombres de sus métodos públicos —SendMessage sobrevive—, mientras que el campo privado _store, el cableado del constructor y cualquier ayudante privado dentro del hub igual se renombran porque members: "public" reduce la conservación solo a la superficie pública. La segunda regla fija las interfaces de cliente fuertemente tipadas (aquí reconocidas por la convención I…Client) y los nombres de sus miembros, de modo que ReceiveMessage y UserJoined quedan intactos. El comodín final rename barre todo lo demás del ensamblado —tu IMessageStore y su implementación, los detalles internos de los DTO, los interceptores, los registros de DI— hacia el renombrado completo.

Si adoptaste [HubMethodName] en cada método de hub y nombres explícitos en la interfaz de cliente, puedes ajustar incluso las dos primeras reglas, porque los nombres de cable ya no viven en los identificadores: solo importan los valores de los atributos, y esos son literales de cadena que Nebula conserva. Para un único método de hub que es la excepción y no la regla, un atributo sobre el miembro exacto es más preciso que un patrón de configuración y vive junto al código:

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

Ejecuta la protección como parte de la compilación de publicación, no en las máquinas de los desarrolladores:

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

O invoca el CLI directamente contra una compilación publicada:

nebula protect --config nebula.config.json

Verificar la superficie con Glass.NET

Una lista de conservación es una afirmación sobre qué nombres sobrevivieron, y conviene comprobar la afirmación en lugar de confiar en ella. Abre el ensamblado protegido en Glass.NET, el descompilador .NET gratuito de Delta1 Labs, y observa dos cosas.

Primero, la superficie conservada. Tu ChatHub debería seguir mostrando métodos públicos llamados SendMessage (o los nombres de cable que fijaste), y la interfaz IChatClient debería seguir listando ReceiveMessage y UserJoined. Estas son las cadenas que tus clientes envían y escuchan; si alguna salió renombrada a a, tu patrón de conservación la pasó por alto y el despacho en tiempo real fallará en producción.

Segundo, todo lo que hay detrás de la superficie. El campo privado _store del hub, el cuerpo de SendMessage, la implementación de IMessageStore y los detalles internos de tus DTO deberían mostrar todos identificadores renombrados y —con encryptStrings activado— constantes de cadena opacas. Esa es la protección que buscabas, intacta exactamente en el código que vale la pena proteger.

La regla que subyace a todo esto es la misma que obedece cualquier framework guiado por reflexión: un nombre es crítico exactamente cuando algo ajeno a la propia resolución de tokens del CLR lo lee como cadena. En SignalR ese conjunto es pequeño y conocible —los nombres de los métodos públicos de hub y los miembros de la interfaz de cliente fuertemente tipada— y una vez que has fijado precisamente esos, Nebula renombra con libertad los cuerpos del hub, los servicios y los DTO, protegiendo tu propia aplicación en tiempo real publicada sin mover jamás un nombre del que dependa un cliente.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.