async/await is the most aggressive source-to-IL transform the C# compiler performs in everyday code. The method you wrote does not exist in the assembly. In its place the compiler emits a generated struct, a jump table keyed on an integer, and a careful choreography of awaiter calls — a state machine that resumes where it left off every time an await completes. A decompiler that cannot recognise that pattern shows you the machine instead of the method, and the machine is almost unreadable. Rebuilding the async method from it is one of the most satisfying things a decompiler does.
Here is what the compiler produces, and how Glass.NET turns it back into source.
What the compiler actually emits
Take a method that could not be more ordinary:
public async Task<int> GetLengthAsync(string url)
{
var text = await _http.GetStringAsync(url);
return text.Length;
}
After compilation, GetLengthAsync itself is a stub. Its entire body creates a generated struct, hands it to the builder, and returns the builder's Task:
public Task<int> GetLengthAsync(string url)
{
var sm = new <GetLengthAsync>d__3();
sm._http = _http;
sm.url = url;
sm.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
sm.<>1__state = -1;
sm.<>t__builder.Start(ref sm);
return sm.<>t__builder.Task;
}
The real logic now lives in MoveNext() on the compiler-generated <GetLengthAsync>d__3 struct. Its locals have become fields (so they survive across a suspend/resume), and the linear code you wrote has been chopped at every await into a resumable block, selected by <>1__state:
void IAsyncStateMachine.MoveNext()
{
int result;
try
{
TaskAwaiter<string> awaiter;
if (<>1__state != 0) // first entry
{
awaiter = _http.GetStringAsync(url).GetAwaiter();
if (!awaiter.IsCompleted)
{
<>1__state = 0; // remember where to resume
<>u__1 = awaiter; // park the awaiter in a field
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return; // unwind the stack, yield control
}
}
else // resumed after await completed
{
awaiter = <>u__1;
<>u__1 = default;
<>1__state = -1;
}
string text = awaiter.GetResult();
result = text.Length;
}
catch (Exception ex)
{
<>1__state = -2;
<>t__builder.SetException(ex);
return;
}
<>1__state = -2;
<>t__builder.SetResult(result);
}
Two readable lines became forty lines of control flow, a field-parked awaiter, and a jump that re-enters the method in the middle. That is what a naive decompiler faithfully prints — and it is correct, it is just not what anyone wrote.
How Glass.NET recognises it
Glass does not pattern-match on names — obfuscation renames <GetLengthAsync>d__3 to something unprintable, and the <> ornaments are a compiler convention, not a guarantee. It matches on structure and contract, which survive renaming:
- A type that implements
IAsyncStateMachineand is driven by one of the known builder types (AsyncTaskMethodBuilder,AsyncTaskMethodBuilder<T>,AsyncValueTaskMethodBuilder<T>, orAsyncVoidMethodBuilder). - A single
intstate field read at the top ofMoveNextand written before everyreturn. - The stub method whose only job is to create the struct, seed it, and call
builder.Start.
Once Glass is confident it is looking at an async state machine, reconstruction is a four-phase job:
-
Recover the shape. The
<>1__statefield and theswitch/branch at the top ofMoveNextdefine the resume points. Glass builds a map from each state value to the IL offset it resumes at — this is the skeleton everything else hangs on. -
Undo the hoisting. Every field that is really a local (assigned and consumed within one logical segment, never observed across a suspend as shared state) is lifted back to a local variable with its original-ish scope. Fields that genuinely carry state across an
await— like the parked awaiter — are recognised as plumbing and dropped. -
Fold the awaiter dance. The
GetAwaiter/IsCompleted/AwaitUnsafeOnCompleted/return/ re-enter /GetResultsequence is the signature of a singleawaitexpression. Glass collapses the whole five-step pattern back into oneawait _http.GetStringAsync(url). -
Re-linearise. With awaits folded and the state jump-table understood, the segmented blocks are stitched back into straight-line code, and
builder.SetResult(x)/SetExceptionbecomereturn x;and ordinary exception flow.
The result Glass prints is the two-line method you started with, not the forty-line machine.
What obfuscation does and does not hide here
This is where expectations need calibrating, because async decompilation is a good lens on obfuscation in general.
Renaming does not hide the pattern. An obfuscator can rename the state-machine type, the builder field, the state field and every hoisted local to noise. It changes nothing for a structural matcher: IAsyncStateMachine is an interface contract the runtime requires, the builder types are framework types that cannot be renamed, and the int-state jump is mechanical. Glass reconstructs renamed async methods exactly as well as un-renamed ones — the names it prints are synthesised, but the control flow is the real thing.
Control-flow obfuscation does reach inside. MoveNext is just a method, so a tool like Nebula.NET can flatten it — dissolve its jump table into a dispatcher loop with opaque predicates, so the clean "state 0 resumes here" mapping that phase 1 depends on is buried under synthetic states. Now Glass has two layers to peel: the obfuscator's flattening and the compiler's state machine, and it cannot always cleanly separate which switch is which. It still produces correct, compilable code — the semantics are preserved — but the tidy await folding degrades into something more literal the more aggressively the method was flattened. That is the honest boundary: decompilation recovers behaviour reliably; it recovers the original shape only as far as the obfuscation let the shape survive.
The practical reading for both sides: if you ship un-obfuscated async code, assume a decompiler reconstructs it almost verbatim — the state machine is not protection. If you need the logic inside an async method protected, the compiler's transform is not enough; you need a real control-flow pass applied on top, and even then the goal is cost, not impossibility.
Why this matters when you are reading someone else's binary
Most of the time you reach for a decompiler you are not attacking anything — you are debugging a third-party library with no source, checking what a dependency really does on a background thread, or recovering your own code from a build whose sources are lost. Async methods are exactly where the stakes are highest: the bug is a race, a swallowed exception in a catch that became SetException, or a ConfigureAwait that is or is not there. A decompiler that hands you the raw state machine makes those invisible. One that folds it back puts the await — and the missing ConfigureAwait(false) — right where you can see it.
Glass.NET is a free, VS-native .NET decompiler that reconstructs async/await, iterators, and yield state machines back to readable C#. Download Glass.NET and open your first assembly.
Top comments (0)