DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

Obfuscating async/await and iterators: why the state machine survives renaming (and the one binding a naive renamer breaks)

Decompile an obfuscated assembly that uses async/await and the output is alarming. Methods you wrote as a tidy sequence of awaits come back as a type you never declared — something like a with fields b, c, d — containing a single method, MoveNext, that is a flattened switch over an integer field with gotos jumping between cases. It looks like the obfuscator mangled your async code into something that cannot possibly still work.

It works perfectly. What you are looking at is two layers of transformation stacked on each other, and neither changed behaviour. The C# compiler got there first: async and yield are not runtime features, they are compiler features, and the compiler already rewrote your linear method into a resumable state machine before the obfuscator ever saw it. The obfuscator then renamed the compiler's generated names and flattened the control flow further. The reason the result still runs is the same reason ordinary renaming is safe: every link in an async method is a metadata token, not a name lookup — with exactly one binding a careless tool can sever. This post shows the lowering, points at the free names and the load-bearing binding, and settles the one real decision you have to make: whether to keep the state-machine attributes.

What async actually compiles to

Start with a method as simple as they come:

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

The compiler splits this into two artifacts. The first is a stub that keeps the method's original name and signature — this is the part callers bind to — and does almost nothing except set up and start a state machine:

.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;
}
Enter fullscreen mode Exit fullscreen mode

The second artifact is the state machine type — a compiler-generated struct that holds the method's logic:

.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 { ... }
}
Enter fullscreen mode Exit fullscreen mode

Your single await became a switch (<>1__state) inside MoveNext: state -1 runs up to the await, parks the TaskAwaiter in <>u__1, sets <>1__state = 0, and returns; when the task completes the builder calls MoveNext again, the switch jumps to the resume point, reads the result, and runs the rest. Your local body became the field <body>5__1 because a local cannot survive a method returning and being re-entered — a field on the state machine can.

Why renaming leaves all of this intact

Look at what connects the pieces and you see the same thing every time: a token, not a name. The stub creates <SumAsync>d__0 by a newobj/initobj against a TypeRef/TypeDef token; it reads and writes <>1__state and <>t__builder through ldfld/stfld against FieldDef tokens; the builder's Start<TStateMachine>(ref TStateMachine) is generic and calls MoveNext through the IAsyncStateMachine constraint. None of these is a reflective, string-keyed lookup. The CLR resolves tokens; it never spells out "<>1__state" to find the field.

That is exactly why renaming is safe here for the same reason it is safe anywhere in managed code. Rename the state-machine type to a, rename <>1__state to b, rename <>t__builder to c, rename the hoisted locals — and every ldfld, every newobj, every call still points at the right token because the obfuscator rewrote the token's target consistently. The MoveNext body still switches on b, the builder still starts a, the method still returns the right Task. The decompiled output is hideous; the behaviour is identical.

The one binding a naive renamer breaks

There is a single genuine hazard, and it is the reason "a good obfuscator understands async" is not marketing. MoveNext and SetStateMachine are not ordinary methods — they are the implementation of IAsyncStateMachine. The builder's Start<TStateMachine> calls MoveNext through the interface, via the where TStateMachine : IAsyncStateMachine constraint. For that call to bind, the runtime must be able to see that the state machine's MoveNext is the method that implements IAsyncStateMachine.MoveNext.

A renamer that treats MoveNext as just another name and rewrites it to a() — without touching the interface implementation record — severs that link. The type still nominally implements IAsyncStateMachine, but now neither of its methods is named like the interface's, and there is no MethodImpl (.override) record saying "a is IAsyncStateMachine.MoveNext." The constrained call inside Start resolves to nothing, and the very first await throws at runtime with no compile-time warning. This is the real origin of "obfuscation broke my async," and it is entirely avoidable.

A correct obfuscator does one of two things: leave the names of interface-implementing methods alone, or rename them and emit the explicit MethodImpl that re-binds a to IAsyncStateMachine.MoveNext. Nebula does the latter where it renames and preserves the binding either way — it reads the interface implementation from metadata rather than guessing from the method name, so MoveNext, SetStateMachine, and the iterator members below are renamed as aggressively as anything else while the contract they satisfy stays intact. You do not list your async methods anywhere; auto-detection sees the generated types and the interfaces they implement.

Iterators are the same story with more interface surface

yield return lowers the same way, into a type that implements IEnumerable<T>/IEnumerator<T>:

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

The generated <EvensUpTo>d__0 carries the familiar <>1__state and a <>2__current field holding the value Current returns, and it implements the enumerator interface — so MoveNext, get_Current, Dispose, and Reset are all interface members, every one of which a foreach invokes through the interface. It also has a <>l__initialThreadId field: the compiler records the thread the iterator was created on so that enumerating it on that same thread can reuse the single instance instead of allocating a fresh one. Async iterators (IAsyncEnumerable<T>) extend the pattern with an AsyncIteratorMethodBuilder and a DisposeAsync.

Every name here is free to rename — <EvensUpTo>d__0, <>2__current, <>l__initialThreadId, the lot — and the same single rule applies: the interface implementation binding for MoveNext/Current/Dispose must be preserved, or a foreach over the obfuscated iterator stops resolving. Nebula treats the iterator's generated type exactly like the async one.

The attribute decision: correctness versus comfort

That leaves the [AsyncStateMachine(typeof(...))] on the stub (and [IteratorStateMachine(typeof(...))] on an iterator). Its typeof points at the generated type, and when the obfuscator renames that type the attribute's token updates with it — so after renaming the attribute still names the right (now-renamed) type. The real question is whether to keep it at all.

The CLR does not read these attributes to run async or iterator code. They exist for tooling: a debugger uses them to stitch together an async call stack and to step from an await into the continuation, and some stack-trace demanglers use them to present a MoveNext frame under your original method name. For a shipped, protected build, stripping them is the right call — they are a map you would otherwise be handing to anyone who attaches a debugger, and removing them costs you nothing at runtime:

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

metadataHardening strips compile-time-only metadata — the nullable annotation attributes, and the state-machine attributes among them — while preserving everything the runtime binds against: the interfaces, the MethodImpl records, the builder references. Your async code runs identically; a reader just loses the convenience of having the state machines labelled. If you need the labels for your own crash analysis, keep an obfuscated PDB (pdbMode: "obfuscated") for the build you ship and analyse against that, rather than leaving the attributes in the binary for everyone.

The takeaway

An obfuscated async method looks broken and is not, because async was already a compiler rewrite before the obfuscator touched it, and because every connection in that rewrite is a token the loader resolves — not a name anyone spells out at runtime. Rename the state-machine type, its state field, its builder, its hoisted locals: all free. The one thing that is not free is the interface binding on MoveNext, SetStateMachine, Current and Dispose — rename those without rewriting the MethodImpl and the first await or foreach dies. That is the line between an obfuscator that understands the CLR and one that just renames strings. Get it right and your async code comes out as hard to read as the rest of your assembly and behaves exactly as it always did.

For the decompiler's view of the same machinery, see what async/await decompiles to and decompiling yield return; for the broader "what resolves by name and must be preserved" checklist, see what obfuscation breaks: reflection and serialization. Download Nebula.NET free and run your async-heavy test suite against the obfuscated build.

Top comments (0)