Table of Contents
讲一下不同语言下异步函数的逆向
异步即 Asynchronous,是程序处理可能会等待的操作的一种方式。常见编译结果是把顺序代码拆成多个恢复点,并用状态字段保存当前执行位置和跨恢复点存活的局部变量。
本文的实验代码在 reverse-async-lab.zip,源码目录是 examples/reverse-async/,可以用下面的命令重新生成文中的反编译和汇编产物:
./examples/reverse-async/scripts/build-artifacts.sh
本文实验工具版本:
rustc 1.96.0 stable
.NET SDK 8.0.128
clang++ 22.1.6
#
Rust
先从 Rust 开始,因为 Rust 的 async 比较直接地暴露了异步的本质:async fn 返回一个实现了 Future 的匿名类型,执行器反复调用它的 poll,每次 poll 要么返回 Poll::Pending,要么返回 Poll::Ready(value)。
实验代码是这个:
pub async fn add_after_two_steps(seed: i32) -> i32 {
let local = seed + 1;
OnePending::new().await;
let after_delay = local * 2;
OnePending::new().await;
after_delay + 3
}
OnePending 是一个很小的 Future,第一次 poll 返回 Pending,第二次返回 Ready(())。这样可以稳定地产生两个暂停点。
从逆向角度看,这段代码不应该理解成“函数卡在 await 那一行”。更准确的模型是:
add_after_two_steps(seed)
-> 返回一个 Future 对象
executor poll future
state 0:
local = seed + 1
poll first awaiter
if Pending:
保存 local 和 awaiter
state = 3
return Pending
state 3:
恢复 first awaiter
after_delay = local * 2
poll second awaiter
if Pending:
保存 after_delay 和 awaiter
state = 4
return Pending
state 4:
恢复 second awaiter
return Ready(after_delay + 3)
Rust MIR 里能直接看到这个状态机结构。完整输出在 rust-async-mir.txt,关键部分如下:
_28 = discriminant((*_29));
switchInt(move _28) -> [0: bb1, 1: bb24, 2: bb23, 3: bb21, 4: bb22, otherwise: bb8];
debug local => (((*_29) as variant#3).0: i32);
debug __awaitee => (((*_29) as variant#3).1: OnePending);
debug after_delay => (((*_29) as variant#4).0: i32);
debug __awaitee => (((*_29) as variant#4).1: OnePending);
_0 = Poll::<i32>::Pending;
discriminant((*_29)) = 3;
return;
_0 = Poll::<i32>::Ready(move _27);
discriminant((*_29)) = 1;
return;
这里的 discriminant 就是状态。variant#3 保存第一个 await 之后恢复所需的数据,variant#4 保存第二个 await 之后恢复所需的数据。local 和 after_delay 不再是普通栈变量,而是 Future 对象里的字段。
为了让汇编更容易读,我在同一个 Rust 文件里写了一个等价的手写状态机:
#[repr(C)]
pub struct ManualAdd {
state: u8,
seed: i32,
local: i32,
after_delay: i32,
}
#[unsafe(no_mangle)]
pub extern "C" fn manual_add_poll(machine: &mut ManualAdd) -> PollI32 {
match machine.state {
0 => {
machine.local = machine.seed + 1;
machine.state = 1;
PollI32 { tag: 0, value: 0 }
}
1 => {
machine.after_delay = machine.local * 2;
machine.state = 2;
PollI32 { tag: 0, value: 0 }
}
2 => {
machine.state = 3;
PollI32 { tag: 1, value: machine.after_delay + 3 }
}
_ => PollI32 { tag: 2, value: -1 },
}
}
它编译后的 x86-64 汇编可以直接查看。正文引用关键部分:
manual_add_poll:
movzbl (%rdi), %eax
cmpl $2, %eax
je .LBB2_6
cmpl $1, %eax
je .LBB2_5
testl %eax, %eax
jne .LBB2_3
movl 4(%rdi), %eax
incl %eax
movl %eax, 8(%rdi)
movb $1, (%rdi)
xorl %eax, %eax
retq
.LBB2_5:
movl 8(%rdi), %eax
addl %eax, %eax
movl %eax, 12(%rdi)
movb $2, (%rdi)
xorl %eax, %eax
retq
.LBB2_6:
movb $3, (%rdi)
movl 12(%rdi), %eax
addl $3, %eax
shlq $32, %rax
incq %rax
retq
SysV x86-64 ABI 下,第一个参数在 rdi,所以这里的 rdi 是 &mut ManualAdd。
字段偏移如下:
machine + 0 state
machine + 4 seed
machine + 8 local
machine + 12 after_delay
逆向时看到这种结构,重点不是纠结变量名,而是先认出:
读 state
-> 按 state 跳转
-> 保存跨 await 存活的变量
-> 写入下一个 state
-> 返回 Pending 或 Ready
Rust 的实际 async fn 还会牵涉 Pin、drop 状态和布局优化,所以真实 Future 的内存布局不保证和这个手写结构一致。但思想是一样的:状态编号加上跨暂停点存活的数据。
#
C#
C# 是逆向异步时最常遇到 MoveNext 的语言。示例代码:
public static async Task<int> FetchAndAddAsync(int seed)
{
int local = seed + 1;
await Task.Delay(10);
int afterDelay = local * 2;
await Task.Yield();
return afterDelay + 3;
}
编译前它看起来像普通顺序代码。编译后,Roslyn 会生成一个状态机类型,并在原方法里创建这个状态机:
[AsyncStateMachine(typeof(<FetchAndAddAsync>d__1))]
public static Task<int> FetchAndAddAsync(int seed)
{
<FetchAndAddAsync>d__1 stateMachine;
stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.seed = seed;
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
真实字段可以从 IL dump 里看到,关键部分如下:
State machine type: ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1
Fields:
public System.Int32 <>1__state
public System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32> <>t__builder
public System.Int32 seed
private System.Int32 <local>5__2
private System.Int32 <afterDelay>5__3
private System.Runtime.CompilerServices.TaskAwaiter <>u__1
private System.Runtime.CompilerServices.YieldAwaitable+YieldAwaiter <>u__2
这些字段基本就已经把状态机说完了:
<>1__state 当前状态
<>t__builder 负责创建和完成 Task<int>
seed 原方法参数
<local>5__2 跨 await 存活的局部变量
<afterDelay>5__3 跨 await 存活的局部变量
<>u__1 第一个 await 的 awaiter
<>u__2 第二个 await 的 awaiter
##
MoveNext 有什么用
MoveNext() 是状态机真正的执行入口。用户代码不会直接调用它,但下面这些地方会调用它:
第一次进入:
AsyncTaskMethodBuilder.Start(ref stateMachine)
-> stateMachine.MoveNext()
await 没完成:
保存 awaiter
state = 下一个恢复状态
builder.AwaitUnsafeOnCompleted(...)
-> 注册 continuation
awaiter 完成后:
continuation
-> stateMachine.MoveNext()
函数正常结束:
builder.SetResult(result)
函数抛异常:
builder.SetException(exception)
所以 MoveNext 的本质是“恢复点分发器”。它不是普通循环里的 MoveNext,而是每次被 continuation 唤醒时,根据 <>1__state 跳到对应的恢复位置。
真实 IL 开头就是状态分发:
IL_0000: ldarg.0
IL_0001: ldfld ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>1__state
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: brfalse.s IL_0058
IL_000a: ldloc.0
IL_000b: ldc.i4.1
IL_000c: beq IL_00c1
等价控制流是:
if state == 0:
goto resume_after_TaskDelay
if state == 1:
goto resume_after_TaskYield
else:
goto method_entry
第一个 await Task.Delay(10) 没完成时,IL 会保存状态和 awaiter:
IL_0035: ldarg.0
IL_0036: ldc.i4.0
IL_0039: stfld ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>1__state
IL_003e: ldarg.0
IL_003f: ldloc.2
IL_0040: stfld ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>u__1
IL_0045: ldarg.0
IL_0046: ldflda ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>t__builder
IL_004b: ldloca.s V_2
IL_004d: ldarg.0
IL_004e: call System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>::AwaitUnsafeOnCompleted
IL_0053: leave IL_011c
恢复时,它把 awaiter 取回来,清空字段,再把状态设回 -1:
IL_0058: ldarg.0
IL_0059: ldfld ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>u__1
IL_005e: stloc.2
IL_005f: ldarg.0
IL_0060: ldflda ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>u__1
IL_0065: initobj System.Runtime.CompilerServices.TaskAwaiter
IL_006b: ldarg.0
IL_006c: ldc.i4.m1
IL_006f: stfld ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>1__state
IL_0074: ldloca.s V_2
IL_0076: call System.Runtime.CompilerServices.TaskAwaiter::GetResult
最后正常结束时:
IL_0108: ldarg.0
IL_0109: ldc.i4.s -2
IL_010b: stfld ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>1__state
IL_0110: ldarg.0
IL_0111: ldflda ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>t__builder
IL_0116: ldloc.1
IL_0117: call System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>::SetResult
如果中间抛异常,则进入 SetException 分支:
IL_00ef: stloc.s V_5
IL_00f1: ldarg.0
IL_00f2: ldc.i4.s -2
IL_00f4: stfld ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>1__state
IL_00f9: ldarg.0
IL_00fa: ldflda ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1::<>t__builder
IL_00ff: ldloc.s V_5
IL_0101: call System.Runtime.CompilerServices.AsyncTaskMethodBuilder<System.Int32>::SetException
##
编译前和编译后有什么不同
编译前:
FetchAndAddAsync
local 是栈变量
afterDelay 是栈变量
await 看起来像暂停当前函数
return 看起来直接返回 int
编译后:
FetchAndAddAsync
只负责创建状态机和返回 Task<int>
<FetchAndAddAsync>d__1.MoveNext
seed/local/afterDelay 变成状态机字段
awaiter 变成状态机字段
return value 通过 builder.SetResult 写进 Task
exception 通过 builder.SetException 写进 Task
恢复点通过 <>1__state 分发
先用线性伪代码表示 MoveNext:
struct Program___FetchAndAddAsync_d__1
{
int32 state; // <>1__state
AsyncTaskMethodBuilder<int> builder; // <>t__builder
int32 seed;
int32 local; // <local>5__2
int32 afterDelay; // <afterDelay>5__3
TaskAwaiter awaiter1; // <>u__1
YieldAwaiter awaiter2; // <>u__2
};
MoveNext:
loc_0000:
eax = this->state
if (eax == 0) goto resume_delay
if (eax == 1) goto resume_yield
entry:
this->local = this->seed + 1
awaiter1 = Task.Delay(10).GetAwaiter()
if (!awaiter1.IsCompleted) {
this->state = 0
this->awaiter1 = awaiter1
builder.AwaitUnsafeOnCompleted(&awaiter1, this)
return
}
resume_delay:
awaiter1 = this->awaiter1
this->awaiter1 = default
this->state = -1
awaiter1.GetResult()
this->afterDelay = this->local * 2
awaiter2 = Task.Yield().GetAwaiter()
if (!awaiter2.IsCompleted) {
this->state = 1
this->awaiter2 = awaiter2
builder.AwaitUnsafeOnCompleted(&awaiter2, this)
return
}
resume_yield:
awaiter2 = this->awaiter2
this->awaiter2 = default
this->state = -1
awaiter2.GetResult()
builder.SetResult(this->afterDelay + 3)
this->state = -2
return
exception:
this->state = -2
builder.SetException(exception)
Native 代码里也能看到同样的状态分发。这里的 Graph HTML 来自 NativeAOT 产物导入 IDA 后的 MoveNext,Plain ASM 来自 CoreCLR JIT dump。关键部分:
G_M000_IG02:
mov eax, dword ptr [rdi]
G_M000_IG03:
test eax, eax
je G_M000_IG05
cmp eax, 1
je G_M000_IG09
mov eax, dword ptr [rdi+0x04]
inc eax
mov dword ptr [rdi+0x08], eax
这里 rdi 是 this,[rdi] 是状态字段。JIT 把 state == 0 和 state == 1 变成了条件跳转,把 seed + 1 变成 mov/inc/mov。后面能看到保存状态和 awaiter 的代码:
mov dword ptr [rbx], edi
lea rdi, bword ptr [rbx+0x18]
mov rsi, gword ptr [rbp-0x20]
call CORINFO_HELP_CHECKED_ASSIGN_REF
lea rsi, bword ptr [rbx+0x10]
mov rdi, rbx
call [System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1[int]:GetStateMachineBox[ReverseAsync.CSharp.Program+<FetchAndAddAsync>d__1](byref,byref):System.Runtime.CompilerServices.IAsyncStateMachineBox]
逆向 C# async 时,MoveNext 比原来的 async 方法重要得多。原方法通常只是壳,真正的控制流、异常处理、等待恢复都在 MoveNext。
##
编译器行为上的坑
几个常见点:
await有快路径。如果awaiter.IsCompleted == true,MoveNext会继续向下执行,不会真的挂起。state == -1通常表示正在运行或尚未挂起,state == -2通常表示完成,但不要把具体数字当 ABI。- Debug 和 Release 差别很大。Debug 会保留更多局部变量和更直观的状态,Release 会内联、合并、消除变量。
- JIT 的字段偏移不等于 IL 字段顺序的直接文本表示。值类型嵌套、引用字段、对齐和泛型 builder 都会影响布局。
await默认会捕获当前上下文。在 UI 程序和 Unity 里,这会影响 continuation 回到哪个线程。- async 方法的异常不会像普通同步函数那样直接抛给调用者,而是写进返回的
Task。async void是例外,它会把异常送到同步上下文,逆向时更难追。
#
Unity IL2CPP
Unity 的 IL2CPP 不会改变 C# async 的第一阶段。编译流程是:
C# async source
-> Roslyn 编译成 IL
-> 生成 IAsyncStateMachine / MoveNext
-> IL2CPP 把 IL 转成 C++
-> C++ 编译器生成 native x86-64/ARM64
也就是说,到了 IL2CPP 之前,MoveNext 已经存在。IL2CPP 只是把这个状态机结构翻译成 C++ 结构体和 C++ 函数,再让 clang/MSVC 编译成本机代码。
本文附件里有一个 Unity 脚本 ReverseAsyncBehaviour.cs:
private static async Task<int> FetchAndAddAsync(int seed)
{
int local = seed + 1;
await Task.Delay(10);
int afterDelay = local * 2;
await Task.Yield();
return afterDelay + 3;
}
IL2CPP 构建后,生成的 C++ 输出中会保留 async 状态机类型和对应的 MoveNext 函数。定位时搜索:
FetchAndAddAsync
MoveNext
U3CFetchAndAddAsyncU3Ed
状态机结构的核心字段形态如下:
struct U3CFetchAndAddAsyncU3Ed__1_t
{
int32_t ___U3CU3E1__state;
AsyncTaskMethodBuilder_1_t ___U3CU3Et__builder;
int32_t ___seed;
int32_t ___U3ClocalU3E5__2;
int32_t ___U3CafterDelayU3E5__3;
TaskAwaiter_t ___U3CU3Eu__1;
YieldAwaiter_t ___U3CU3Eu__2;
};
对应的 MoveNext 函数形态如下:
void U3CFetchAndAddAsyncU3Ed__1_MoveNext(U3CFetchAndAddAsyncU3Ed__1_t* __this)
{
int32_t state = __this->___U3CU3E1__state;
if (state == 0) {
goto resume_delay;
}
if (state == 1) {
goto resume_yield;
}
// method entry
// save awaiter
// AwaitUnsafeOnCompleted
// SetResult / SetException
}
逆向 IL2CPP async 时有两个重点:
- 优先从
global-metadata.dat和 IL2CPP 符号里恢复类型名、字段名和方法名。 - 找
MoveNext。async 原方法通常只是创建状态机,真正逻辑还是在U3CxxxU3Ed__n_MoveNext。
native 反汇编中对应的控制流通常是下面的形态。实际 IL2CPP 产物可以提供两个入口:Graph HTML 用于查看控制流,Plain ASM 用于复制和检索。
.text:FetchAndAddAsync_MoveNext
mov eax, [rcx+state]
test eax, eax
jz resume_delay
cmp eax, 1
jz resume_yield
entry:
mov eax, [rcx+seed]
inc eax
mov [rcx+local], eax
call Task_Delay
call Task_GetAwaiter
call TaskAwaiter_get_IsCompleted
test al, al
jnz delay_ready
mov [rcx+state], 0
mov [rcx+awaiter1], awaiter
call AsyncTaskMethodBuilder_AwaitUnsafeOnCompleted
ret
IL2CPP 下 C# 的名字会被转义,比如 <FetchAndAddAsync>d__1 变成 U3CFetchAndAddAsyncU3Ed__1。U3C 和 U3E 分别对应 < 和 >。
#
C++20
C++20 coroutine 和 C# / Rust 都不一样。语言只规定 coroutine 机制,不规定 Task、执行器或事件循环。这些由库类型决定。
示例代码:
Task add_after_two_steps(int seed) {
int local = seed + 1;
co_await OneSuspend{};
int after_delay = local * 2;
co_await OneSuspend{};
co_return after_delay + 3;
}
clang 编译后的汇编可以直接查看。创建 coroutine frame 的部分:
_Z19add_after_two_stepsi:
mov edi, 32
call _Znwm@PLT
lea rcx, [rip + _Z19add_after_two_stepsi.resume]
mov qword ptr [rax], rcx
lea rcx, [rip + _Z19add_after_two_stepsi.destroy]
mov qword ptr [rax + 8], rcx
mov dword ptr [rax + 20], ebx
mov dword ptr [rax + 16], ebx
mov byte ptr [rax + 24], 0
可以直接看出 frame 里放了:
+0 resume 函数指针
+8 destroy 函数指针
+16 promise/result 相关数据
+20 seed 或跨暂停点变量
+24 状态字节
resume 函数就是状态机:
_Z19add_after_two_stepsi.resume:
movzx eax, byte ptr [rdi + 24]
cmp eax, 2
je .LBB2_4
cmp eax, 1
jne .LBB2_2
mov byte ptr [rdi + 24], 2
ret
.LBB2_4:
mov eax, dword ptr [rdi + 20]
lea eax, [2*rax + 5]
mov dword ptr [rdi + 16], eax
mov qword ptr [rdi], 0
ret
.LBB2_2:
mov byte ptr [rdi + 24], 1
ret
C++20 逆向时常见的关键词不是 MoveNext,而是:
coroutine frame
promise_type
resume function
destroy function
coroutine_handle
优化打开后,编译器可能会把驱动函数完全折叠掉。本文的 drive_cpp20_coroutine 就被 clang 优化成了:
drive_cpp20_coroutine:
lea eax, [2*rdi + 5]
ret
这说明异步状态机不一定总能在最终调用点看到。你要找的是 coroutine 本身的创建函数、resume 函数和 frame 布局。
#
Javascript
JavaScript 的 async/await 更偏运行时。一个 async function 调用后会立即返回 Promise,await 后面的 continuation 会排进 microtask。
源代码:
async function addAfterTwoSteps(seed) {
const local = seed + 1;
await delay(10);
const afterDelay = local * 2;
await Promise.resolve();
return afterDelay + 3;
}
如果目标是现代 JS 引擎,状态机很多时候在引擎内部,不会像 C# 一样在用户代码里看到一个叫 MoveNext 的函数。逆向 JS bundle 时你更可能看到:
Promise
then
async function 原文
minifier 改名后的 async 函数
如果代码被 Babel 或 TypeScript 编译到旧版本 JS,就经常会看到生成器风格状态机:
return __awaiter(this, void 0, void 0, function* () {
const local = seed + 1;
yield delay(10);
const afterDelay = local * 2;
yield Promise.resolve();
return afterDelay + 3;
});
或者更底层的 switch:
switch (_context.prev = _context.next) {
case 0:
local = seed + 1;
_context.next = 3;
return delay(10);
case 3:
afterDelay = local * 2;
_context.next = 6;
return Promise.resolve();
case 6:
return _context.abrupt("return", afterDelay + 3);
}
这和 C# 的 <>1__state、Rust 的 discriminant、C++20 的 frame state 是同一个思想:用一个状态值记录下次从哪里继续。
#
语言之间的差异
最重要的差异是“状态机归谁管”。
Rust:
async fn 返回 Future
编译器生成状态机
执行器在语言外部
poll 由执行器调用
默认不分配堆内存,除非 Box/pin/task 系统需要
C#:
async Task<T> 返回 Task<T>
编译器生成 IAsyncStateMachine
MoveNext 是核心
AsyncTaskMethodBuilder 负责 Task 完成、异常和 continuation
运行时和 Task 调度体系参与很多
C++20:
编译器生成 coroutine frame、resume、destroy
promise_type 决定返回对象行为
标准不提供统一 executor
frame 常见为堆分配,但可被优化或自定义分配
JavaScript:
async function 返回 Promise
continuation 进入 microtask
状态机可能在引擎内部,也可能由 Babel/TS 编译到 JS
逆向时经常面对 minified Promise 链或 generator helper
Unity IL2CPP:
先有 C# 状态机和 MoveNext
再由 IL2CPP 变成 C++ 结构体和函数
最后由 C++ 编译器变成本机代码
逆向时可以按下面的顺序处理:
1. 先找 async 函数的返回类型
Task / Future / Promise / coroutine Task
2. 找状态字段
C#: <>1__state
Rust: discriminant / enum variant
C++20: coroutine frame 里的 state byte
JS transpile: _context.next / switch state
3. 找跨 await 存活的变量
它们一定不再只是普通栈变量
4. 找 awaiter / continuation 注册
C#: AwaitUnsafeOnCompleted
Rust: poll + Waker
C++20: await_suspend
JS: Promise.then / microtask
5. 找完成路径
C#: SetResult / SetException
Rust: Poll::Ready / panic unwind
C++20: promise.return_value / unhandled_exception
JS: resolve / reject
异步函数的逆向难点不在于它真的复杂,而在于源码把控制流伪装成了顺序代码。只要把它还原成“状态字段 + 保存的局部变量 + 恢复函数”,大部分 async/await 都会变回普通控制流。