Melaton's Blog

逆向异步

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 之后恢复所需的数据。localafter_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

这里 rdithis[rdi] 是状态字段。JIT 把 state == 0state == 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

## 编译器行为上的坑

几个常见点:

  1. await 有快路径。如果 awaiter.IsCompleted == trueMoveNext 会继续向下执行,不会真的挂起。
  2. state == -1 通常表示正在运行或尚未挂起,state == -2 通常表示完成,但不要把具体数字当 ABI。
  3. Debug 和 Release 差别很大。Debug 会保留更多局部变量和更直观的状态,Release 会内联、合并、消除变量。
  4. JIT 的字段偏移不等于 IL 字段顺序的直接文本表示。值类型嵌套、引用字段、对齐和泛型 builder 都会影响布局。
  5. await 默认会捕获当前上下文。在 UI 程序和 Unity 里,这会影响 continuation 回到哪个线程。
  6. async 方法的异常不会像普通同步函数那样直接抛给调用者,而是写进返回的 Taskasync 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 时有两个重点:

  1. 优先从 global-metadata.dat 和 IL2CPP 符号里恢复类型名、字段名和方法名。
  2. 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__1U3CU3E 分别对应 <>

# 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 调用后会立即返回 Promiseawait 后面的 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 都会变回普通控制流。