Skip to content
← Todas las publicaciones
· Delta1 Labs Nebula.NETOfuscación.NETAnálisis a fondo

Ofuscar async/await e iteradores: por qué la máquina de estados sobrevive al renombrado (y el único enlace que un renombrador ingenuo rompe)

Abre un ensamblado ofuscado y decompilado y lo más aterrador que ves es un MoveNext lleno de flujo de control aplanado y campos llamados <>1__state. La gente asume que el renombrado debió de romper su código async. No lo hizo —porque cada enlace entre un método async, su tipo de máquina de estados generado y el AsyncTaskMethodBuilder es un token de metadatos, no una búsqueda por nombre, así que renombrar el tipo y sus campos a a y b no cambia nada que el runtime lea—. Pero hay una cosa que un ofuscador descuidado sí puede romper: MoveNext, SetStateMachine, Current y Dispose son métodos de interfaz, y renombrarlos sin reescribir el enlace de interfaz (el MethodImpl) deja al builder llamando a un método que ya no implementa IAsyncStateMachine. Esta entrada lee el rebajado real de un método async y de un iterador, muestra qué nombres son libres y qué enlace debe preservarse, y explica el compromiso del endurecimiento de metadatos: elimina el atributo AsyncStateMachine para una compilación de release y entiende que cuesta depuración paso a paso de async, no corrección.

Decompila un ensamblado ofuscado que usa async/await y la salida es alarmante. Métodos que escribiste como una secuencia ordenada de await vuelven como un tipo que nunca declaraste —algo como a con campos b, c, d— que contiene un único método, MoveNext, que es un switch aplanado sobre un campo entero con goto saltando entre casos. Parece que el ofuscador destrozó tu código async hasta algo que no puede seguir funcionando.

Funciona perfectamente. Lo que estás mirando son dos capas de transformación apiladas una sobre otra, y ninguna cambió el comportamiento. El compilador de C# llegó primero: async y yield no son características del runtime, son características del compilador, y el compilador ya reescribió tu método lineal en una máquina de estados reanudable antes de que el ofuscador la viera siquiera. El ofuscador luego renombró los nombres generados por el compilador y aplanó más el flujo de control. La razón de que el resultado siga corriendo es la misma por la que el renombrado corriente es seguro: cada enlace en un método async es un token de metadatos, no una búsqueda por nombre —con exactamente un enlace que una herramienta descuidada puede cortar—. Esta entrada muestra el rebajado, señala los nombres libres y el enlace que carga el peso, y zanja la única decisión real que tienes que tomar: si conservar los atributos de máquina de estados.

A qué se compila realmente async

Empieza con un método todo lo simple que pueden ser:

public async Task<int> SumAsync(HttpClient http, string url)
{
    string body = await http.GetStringAsync(url);
    return body.Length;
}

El compilador divide esto en dos artefactos. El primero es un stub que conserva el nombre y la firma originales del método —esta es la parte a la que se enlazan los llamadores— y no hace casi nada excepto preparar y arrancar una máquina de estados:

.method public hidebysig instance class [System.Runtime]System.Threading.Tasks.Task`1<int32>
        SumAsync(class [System.Net.Http]System.Net.Http.HttpClient http, string url) cil managed
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [System.Runtime]System.Type)
          = { type('SumAsync>d__0') }                 // <-- points typeof at the state machine
  // new <SumAsync>d__0 sm;
  // sm.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
  // sm.http = http; sm.url = url; sm.<>1__state = -1;
  // sm.<>t__builder.Start(ref sm);                    // generic: Start<TStateMachine>(ref sm)
  // return sm.<>t__builder.Task;
}

El segundo artefacto es el tipo de máquina de estados —un struct generado por el compilador que contiene la lógica del método:

.class nested private sealed auto ansi beforefieldinit
       '<SumAsync>d__0' extends [System.Runtime]System.ValueType
       implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine
{
  .field public int32 '<>1__state'                                      // where to resume
  .field public valuetype ...AsyncTaskMethodBuilder`1<int32> '<>t__builder'
  .field public class [System.Net.Http]...HttpClient http               // hoisted parameter
  .field public string url                                              // hoisted parameter
  .field private string '<body>5__1'                                    // hoisted local
  .field private valuetype ...TaskAwaiter`1<string> '<>u__1'            // the awaiter

  .method private hidebysig newslot virtual final instance void
          MoveNext() cil managed { /* the switch-on-<>1__state body */ }

  .method private hidebysig newslot virtual final instance void
          SetStateMachine(class ...IAsyncStateMachine stateMachine) cil managed { ... }
}

Tu único await se volvió un switch (<>1__state) dentro de MoveNext: el estado -1 corre hasta el await, aparca el TaskAwaiter en <>u__1, fija <>1__state = 0 y retorna; cuando la tarea completa, el builder llama a MoveNext de nuevo, el switch salta al punto de reanudación, lee el resultado y corre el resto. Tu local body se volvió el campo <body>5__1 porque un local no puede sobrevivir a que un método retorne y vuelva a entrar —un campo en la máquina de estados sí.

stub — nombre público conservadoSumAsync(http, url)crea la máquina de estadosbuilder.Start(ref sm)return builder.Task[AsyncStateMachine(typeof(…))]eliminar en release · solo depuracióntipo de máquina de estados → renombrado a a<>1__state → b (resume point)<>t__builder → c (the builder)<body>5__1 → d (hoisted local)nombres de campo y tipo: renombrados librementeMoveNext() : IAsyncStateMachineSetStateMachine() : IAsyncStateMachinerenombrable SOLO si el MethodImpl(enlace de interfaz) también se reescribe

Por qué el renombrado deja todo esto intacto

Mira lo que conecta las piezas y ves lo mismo cada vez: un token, no un nombre. El stub crea <SumAsync>d__0 con un newobj/initobj contra un token TypeRef/TypeDef; lee y escribe <>1__state y <>t__builder mediante ldfld/stfld contra tokens FieldDef; el Start<TStateMachine>(ref TStateMachine) del builder es genérico y llama a MoveNext mediante la restricción IAsyncStateMachine. Ninguno de estos es una búsqueda reflexiva por cadena. El CLR resuelve tokens; nunca deletrea "<>1__state" para encontrar el campo.

Por eso exactamente el renombrado es seguro aquí por la misma razón que lo es en cualquier parte del código gestionado. Renombra el tipo de máquina de estados a a, renombra <>1__state a b, renombra <>t__builder a c, renombra las locales elevadas —y cada ldfld, cada newobj, cada call sigue apuntando al token correcto porque el ofuscador reescribió el objetivo del token de forma consistente—. El cuerpo de MoveNext sigue conmutando sobre b, el builder sigue arrancando a, el método sigue devolviendo la Task correcta. La salida decompilada es horrenda; el comportamiento es idéntico.

El único enlace que un renombrador ingenuo rompe

Hay un único peligro genuino, y es la razón de que “un buen ofuscador entiende async” no sea marketing. MoveNext y SetStateMachine no son métodos ordinarios —son la implementación de IAsyncStateMachine—. El Start<TStateMachine> del builder llama a MoveNext a través de la interfaz, vía la restricción where TStateMachine : IAsyncStateMachine. Para que esa llamada enlace, el runtime debe poder ver que el MoveNext de la máquina de estados es el método que implementa IAsyncStateMachine.MoveNext.

Un renombrador que trata MoveNext como un nombre más y lo reescribe a a() —sin tocar el registro de implementación de interfaz— corta ese enlace. El tipo todavía implementa nominalmente IAsyncStateMachine, pero ahora ninguno de sus métodos se llama como los de la interfaz, y no hay registro MethodImpl (.override) que diga “a es IAsyncStateMachine.MoveNext”. La llamada restringida dentro de Start no resuelve a nada, y el primerísimo await lanza una excepción en tiempo de ejecución sin aviso en compilación. Este es el origen real de “la ofuscación rompió mi async”, y es enteramente evitable.

Un ofuscador correcto hace una de dos cosas: deja en paz los nombres de los métodos que implementan interfaces, o los renombra y emite el MethodImpl explícito que re-enlaza a con IAsyncStateMachine.MoveNext. Nebula hace lo segundo donde renombra, y preserva el enlace en cualquier caso —lee la implementación de interfaz de los metadatos en lugar de adivinar por el nombre del método—, así que MoveNext, SetStateMachine y los miembros de iterador de más abajo se renombran tan agresivamente como cualquier cosa mientras el contrato que satisfacen queda intacto. No listas tus métodos async en ningún sitio; la autodetección ve los tipos generados y las interfaces que implementan.

Los iteradores son la misma historia con más superficie de interfaz

yield return se rebaja igual, en un tipo que implementa IEnumerable<T>/IEnumerator<T>:

public IEnumerable<int> EvensUpTo(int n)
{
    for (int i = 0; i <= n; i += 2)
        yield return i;
}

El <EvensUpTo>d__0 generado lleva el familiar <>1__state y un campo <>2__current que contiene el valor que Current devuelve, e implementa la interfaz de enumerador —así que MoveNext, get_Current, Dispose y Reset son todos miembros de interfaz, cada uno de los cuales un foreach invoca a través de la interfaz—. También tiene un campo <>l__initialThreadId: el compilador registra el hilo en el que se creó el iterador para que enumerarlo en ese mismo hilo pueda reutilizar la única instancia en lugar de asignar una fresca. Los iteradores async (IAsyncEnumerable<T>) extienden el patrón con un AsyncIteratorMethodBuilder y un DisposeAsync.

Cada nombre aquí es libre de renombrar —<EvensUpTo>d__0, <>2__current, <>l__initialThreadId, todo— y aplica la misma regla única: el enlace de implementación de interfaz para MoveNext/Current/Dispose debe preservarse, o un foreach sobre el iterador ofuscado deja de resolver. Nebula trata el tipo generado del iterador exactamente como el de async.

La decisión del atributo: corrección frente a comodidad

Eso deja el [AsyncStateMachine(typeof(...))] en el stub (y [IteratorStateMachine(typeof(...))] en un iterador). Su typeof apunta al tipo generado, y cuando el ofuscador renombra ese tipo el token del atributo se actualiza con él —así que tras el renombrado el atributo sigue nombrando el tipo correcto (ahora renombrado)—. La pregunta real es si conservarlo en absoluto.

El CLR no lee estos atributos para correr código async o de iterador. Existen para herramientas: un depurador los usa para coser una pila de llamadas async y para pasar de un await a la continuación, y algunos desenmarañadores de rastros de pila los usan para presentar un marco MoveNext bajo el nombre de tu método original. Para una compilación enviada y protegida, eliminarlos es lo correcto —son un mapa que de otro modo estarías entregando a cualquiera que adjunte un depurador, y quitarlos no cuesta nada en tiempo de ejecución:

{
  "inputs": ["bin/Release/net8.0/MyApp.dll"],
  "outputDirectory": "protected",
  "controlFlowObfuscation": true,
  "encryptStrings": true,
  "metadataHardening": true
}

metadataHardening elimina metadatos solo de compilación —los atributos de anotación nullable, y los atributos de máquina de estados entre ellos— mientras preserva todo lo que el runtime enlaza: las interfaces, los registros MethodImpl, las referencias al builder. Tu código async corre idéntico; un lector solo pierde la comodidad de tener las máquinas de estados etiquetadas. Si necesitas las etiquetas para tu propio análisis de fallos, conserva un PDB ofuscado (pdbMode: "obfuscated") para la compilación que envías y analizas, en lugar de dejar los atributos en el binario para todos.

La conclusión

Un método async ofuscado parece roto y no lo está, porque async ya era una reescritura del compilador antes de que el ofuscador lo tocara, y porque cada conexión en esa reescritura es un token que el cargador resuelve —no un nombre que alguien deletrea en tiempo de ejecución—. Renombra el tipo de máquina de estados, su campo de estado, su builder, sus locales elevadas: todo libre. Lo único que no es libre es el enlace de interfaz en MoveNext, SetStateMachine, Current y Dispose —renómbralos sin reescribir el MethodImpl y el primer await o foreach muere—. Esa es la línea entre un ofuscador que entiende el CLR y uno que solo renombra cadenas. Hazlo bien y tu código async sale tan difícil de leer como el resto de tu ensamblado y se comporta exactamente como siempre lo hizo.

Para la vista del decompilador de la misma maquinaria, véase a qué se decompila async/await y decompilar yield return; para la lista más amplia de “qué resuelve por nombre y debe preservarse”, véase qué rompe la ofuscación: reflexión y serialización. Descarga Nebula.NET gratis y corre tu suite de pruebas intensiva en async contra la compilación ofuscada.

Prueba Nebula.NET

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