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
- What can the callback reach that is not a resource of this module?
- What was validated before the closure was created that is not re-validated at invocation?
- Is the state update ordered before the dispatch, or is it relying on the abort?
- If the function must not be re-entered at all, is
#[module_lock]warranted? It is the stronger primitive, at the cost of refusing legitimate re-entry too.
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
- Pro: the common case is handled by the platform, with no per-function code and no forgotten guard.
- Con / cost: it is an abort, so a caller can use it to force a failure; and the scope is narrower than the reputation suggests, which is worse than no protection if it is trusted blindly.
- Trust note: an API that accepts a caller-supplied function value is accepting arbitrary code at that point. The lock changes what that code can read, not that it runs.
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
- AIP-112 (resource lock for closure dispatch), AIP-73 (module lock for native dispatch)
aptos-core,third_party/move/move-vm/runtime/src/reentrancy_checker.rs- Aptos, “Move Security Guidelines”, on function values and reentrancy