Skip to the content.

Callback Dispatch and Reentrancy

Category: Safety & Access Control · Covered by: Execution, with a scoped gap · Requires: Aptos Move 2.2 or later, for function values

What this prevents

A callback re-entering a module before its state update is written, so the re-entered code reads a balance that is about to change and acts on it twice.

Accepting a caller-supplied function value means accepting arbitrary code at that point in the call. The platform covers part of the consequence and not all of it, so the useful question is where the line sits.

Coverage

Reentrancy is widely described as impossible in Move. That was accurate before function values, and it is not the statement to build on now. Aptos ships a runtime reentrancy checker, and what it actually prevents is narrower: re-entering a module is allowed, and only access to that module’s own resources during reentrancy is refused.

That places this at execution rather than at load, and the distinction has teeth. A load-time guarantee means the code could not be published. An execution-time guarantee means the transaction aborts, which protects state and simultaneously hands an attacker a way to make a call fail.

What the platform does

Function values reached mainnet with Aptos 1.35, and Aptos’s own security guidance says plainly that they are not locked against reentrancy. State is protected instead by move-vm/runtime/src/reentrancy_checker.rs, which implements two mechanisms. Closure dispatch takes a resource lock (AIP-112): on re-entry, any read of a resource defined by the re-entered module aborts with RUNTIME_DISPATCH_ERROR. Native dispatch, and anything marked #[module_lock], takes a module lock (AIP-73), which refuses re-entry through any call at all; dispatchable fungible assets run under this one.

Measured behaviour, asserted by the tests in src/tests/Reentrancy_test.move: a callback may call other modules freely; it may re-enter the dispatching module as long as the function it calls touches no global storage; and the moment it re-enters and reads one of that module’s resources, execution aborts with major status 4037.

What is left to you

The lock is scoped to resources of the re-entered module, and that scope is the whole audit surface.

Anything the callback can reach that is not one of those resources is fair game during reentrancy. Aptos’s own worked example turns on exactly this: a capability-like value defined in a different module stays usable, so the callback re-invokes the withdrawal path and overdraws. Values passed into the closure are in the same category.

Two further properties compound it. Captured arguments are fixed when the closure is lifted and are not overridden at invocation, so a callback can carry values the caller never sees. And a function value stored in global storage may execute much later or never, so anything validated before the value was created is a time-of-check-to-time-of-use hazard by construction.

Ordering still matters. The lock denies the read; it does not roll back a half-finished update. Writing effects before the dispatch means correctness; writing them after means depending on an abort.

Sample and test

See src/sources/Reentrancy.move, exercised by src/tests/Reentrancy_test.move. The test suite has three cases and each one earns its place: the happy path, the attack, and the gap.

The attack case re-enters the vault from the payout callback and reads the module’s own resource. It is written as #[expected_failure(major_status = 4037, ...)], so the test passes only if the VM’s resource lock actually fires. The third case deliberately passes: a callback operating on the value it was handed touches no resource of the re-entered module, so nothing stops it. A defence that is only demonstrated where it works tells you less than one shown next to its own edge.

Key excerpt:

public fun withdraw(
    owner: &signer,
    amount: u64,
    on_paid: |Withdrawal|u64 has drop,
): u64 acquires Vault {
    let vault = borrow_global_mut<Vault>(signer::address_of(owner));
    assert!(vault.balance >= amount, 1);
    vault.balance = vault.balance - amount;   // effects first, still

    on_paid(Withdrawal { amount })            // interaction last
}

Withdrawal is deliberately defined in this module. Move it to another module and the callback can operate on it during reentrancy, because the lock protects resources, not values.

Review questions

A hand-written reentrancy guard is redundant for the case the VM already covers, and does not extend to the case it does not, so adding one buys a false sense of coverage rather than a defence.

Trade-offs

Historical note

Move’s reentrancy reputation comes from a period when it was earned: without dynamic dispatch there was no mechanism to re-enter with. Function values (Move 2.2, mainnet in Aptos 1.35) introduced one, and AIP-112’s resource lock was the answer. Material written before that release is not wrong about its own era, which is why the claim persists.

Worth watching: FUNCTION_VALUE_DISPATCH is defined but not yet addressable on mainnet. When it activates, dispatchable fungible assets and account abstraction move onto function values, and this page needs re-reading rather than re-dating.

References