Understanding Reentrancy in Aptos Move: Evolution, Challenges, and Protections

Introduction to Move

The Move language is a next-generation smart contract programming language originally designed for the Libra (later Diem) blockchain, a project since discontinued. Today, Move powers platforms like Aptos and Sui. Its hallmark is a focus on safety and expressiveness, particularly regarding digital assets. It enforces strong resource semantics, making it difficult to accidentally lose or duplicate tokens, a significant advantage over traditional smart contract languages.

The Early Days: Move and (the Lack of) Reentrancy

In web3 security, reentrancy vulnerabilities are notorious for enabling high-profile exploits (such as the DAO hack on Ethereum). Reentrancy refers to the execution of a second call to a contract before the first invocation is completed. Move, by design, did not support reentrancy in its early versions.

Dynamic dispatch was not supported, meaning static dispatch was the only way to call a function from another module (a smart contract on Move). This requires declaring the module as a dependency. Since Move enforces acyclic dependency graphs, reentrancy exploits were effectively impossible. Hence, the Move developer community recognised the language as “immune” to reentrancy, a security property perceived as a major advantage over other smart contract platforms.

Modern Aptos Move: Native Dynamic Dispatch

As the Aptos community continued to develop Move (independently of other variations such as Sui Move), the language gained more expressive power, rendering dynamic dispatch possible.

In current Aptos Move (pre-2.2), dynamic calls occur only through native functions in the Aptos framework. These are functions implemented in Rust inside the Aptos VM that can be called from Move code. Native functions can perform actions beyond the scope of pure Move code, including dynamic dispatch. This is called native dynamic dispatch.

These native functions are not typically exposed directly and must instead be accessed through the Aptos framework. However, the Aptos framework provides enough flexibility to enable full dynamic dispatch, for example, by using the dispatchable_fungible_asset module. At first glance, it may seem as though this feature makes Aptos vulnerable to reentrancy; however, the Aptos developers have taken steps to mitigate this.

Native Reentrancy Protections

The Aptos VM employs a ReentrancyChecker designed to secure Aptos against reentrancy attacks. This checker maintains two variables:

  • Active modules map: tracks how many times each module appears in the current call stack
  • Module lock count: tracks a status called “module lock mode”

The core function of this mechanism is the enter_function, invoked during every Move function call. The implementation of this is shown below:

pub fn enter_function(
        &mut self,
        caller_module: Option<&ModuleId>,
        callee: &LoadedFunction,
        call_type: CallType,
    ) -> PartialVMResult<()> {
        if call_type == CallType::NativeDynamicDispatch
            || callee.function.has_module_reentrancy_lock
        {
            // If we enter a native dispatch function, or a function which has marked for locking,
            // also enter module locking mode
            self.enter_module_lock()
        }
        let callee_module = callee.module_or_script_id();
        if Some(callee_module) != caller_module {
            // Cross module call.
            // When module lock is active, and we have already called into this module, this
            // reentry is disallowed
            match self.active_modules.entry(callee_module.clone()) {
                Entry::Occupied(mut e) => {
                    if self.module_lock_count > 0 {
                        return Err(PartialVMError::new(StatusCode::RUNTIME_DISPATCH_ERROR)
                            .with_message(format!(
                                "Reentrancy disallowed: reentering `{}` via function `{}` \
                     (module lock is active)",
                                callee_module,
                                callee.name()
                            )));
                    }
                    *e.get_mut() += 1
                },
                Entry::Vacant(e) => {
                    e.insert(1);
                },
            }
        } else if call_type == CallType::ClosureDynamicDispatch || caller_module.is_none() {
            // If this is closure dispatch, or we have no caller module (i.e. top-level entry).
            // Count the intra-module call like an inter-module call, as reentrance.
            // A static local call is governed by Move's `acquire` static semantics; however,
            // a dynamic dispatched local call has accesses not known at the caller side, so needs
            // the runtime reentrancy check. Note that this doesn't apply to NativeDynamicDispatch
            // which already has a check in place preventing a dispatch into the same module.
            *self
                .active_modules
                .entry(callee_module.clone())
                .or_default() += 1;
        }
        Ok(())
    }

Due to the condition Some(callee_module) != caller_module, the above code distinguishes between direct and indirect reentrancy. Direct reentrancy refers to the case where a module calls itself (Module A -> Module A). Indirect reentrancy refers to the case where a module calls into another module or series of modules, which in turn calls the original module (Module A -> Module B -> module A).

As can be seen from the above code, native dynamic dispatch enters the module lock. When the lock is active, indirect reentrancy is disallowed. This is because indirect reentrancy, in module lock mode, triggers the three conditions required to reach the return of an Err value, reverting the transaction. Therefore, the reentrancy checker renders indirect reentrancy impossible when using native dynamic dispatch.

This might imply that direct reentrancy is still possible; however, careful examination of the native dispatch mechanism reveals the following check:

if target_func.is_friend_or_private() || target_func.module_id() == function.module_id()
    {
        return Err(PartialVMError::new(StatusCode::RUNTIME_DISPATCH_ERROR)
            .with_message(
                "Invoking private or friend function during dispatch".to_string(),
            ));
    }

These combined protections mean that, in the case of native dynamic dispatch, reentrancy is still impossible, and Aptos Move’s reputation as a “safe by design” language remains intact.

Move 2.2 and Closure Dispatch: Reentrancy Revisited

Aptos Move 2.2 includes AIP-112, which introduces closure dispatch to the language and enables more dynamic programming patterns. However, this new expressive power comes with new risks. Closure dispatch does not, by default, enter the module lock. Therefore, nothing prevents reentrant code such as the below example:

module example::victim {
    struct State has key {
        balance: u64,
    }
    const MIN_ALLOWED_BALANCE: u64 = 1000;
    public fun withdraw(caller: &signer, amount: u64, callback: ||()) acquires State {
        let state = borrow_global_mut<State>(signer::address_of(caller));
        assert!(state.balance - amount >= MIN_ALLOWED_BALANCE);
        callback();
        state.balance -= amount;
    }
}

module example::attacker{
    public fun attack() {
        let s: &signer = &attack_helper::get_signer();
        victim::withdraw(s, 1000, ||malicious_callback());
    }
    fun malicious_callback(){
        let s: &signer = &attack_helper::get_signer();
        victim::withdraw(s, 1000, ||()); 
    }
}

Here, the attacker reenters withdraw via the callback closure, bypassing the minimum-balance check. Fortunately, Move’s strong resource semantics still offer a powerful defence. If we test this code, the attack will fail with the following error:

"Resource `@example::victim::State` cannot be accessed because of active reentrancy of defining module."

This demonstrates another layer of protection built into the Aptos VM against reentrancy.

Resource Locking

When a resource is being accessed, Move’s runtime will “lock” that resource, preventing it from being accessed in a reentrant fashion during the same transaction. Under the hood, this is implemented through the below function of the ReentrancyChecker, invoked by all global storage operators:

pub fn check_resource_access(&self, struct_id: &StructIdentifier) -> PartialVMResult<()> {
    if self
        .active_modules
        .get(&struct_id.module)
        .copied()
        .unwrap_or_default()
        > 1
    {
        // If the count is greater one, we have reentered this module, and all
        // resources it defines are locked.
        Err(
            PartialVMError::new(StatusCode::RUNTIME_DISPATCH_ERROR).with_message(format!(
                "Resource `{}` cannot be accessed because of active reentrancy of defining \
                module.",
                struct_id,
            )),
        )
    } else {
        Ok(())
    }
}

This mechanism renders it impossible to access resources of reentered modules, mitigating many potential vulnerabilities, as it prevents access to state with pending updates. However, despite being a powerful defence, this still does not prevent all cases of reentrancy.

Edge Case: Third-Party Resource Reentrancy in Move 2.2

There is a subtle but important edge case in Move 2.2: third-party resources are not locked. This means that, if a contract interacts with a resource owned or managed by a different module, closure-based reentrancy could potentially occur.

For instance, consider the case where the previous reentrancy example is updated to use a storage module to store its state:

module example::victim {
    use example::storage;
    use std::signer;

    const MIN_ALLOWED_BALANCE: u64 = 1000;

    public fun withdraw(caller: &signer, amount: u64, callback: ||()) {
        let addr = signer::address_of(caller);
        assert!(storage::get_balance(addr) - amount >= MIN_ALLOWED_BALANCE);
        callback();
        storage::set_balance(addr, storage::get_balance(addr) - amount);
    }
}

This code is now vulnerable to reentrancy, as the third-party resources accessed are not locked. The previously provided attacker code can be executed successfully, bypassing the minimum-balance check. This highlights a crucial security consideration introduced in Move 2.2 that developers should be aware of when relying on external storage abstractions.

In practice, this design pattern is uncommon and generally should be avoided in Move. However, in cases where it is required, Move provides a mechanism to mitigate certain cases of the vulnerability.

The Module Lock: Explicitly Blocking Closure Reentrancy

Earlier we saw that “module lock mode” prevents reentrancy entirely in the case of native dynamic dispatch. This mode can also be entered explicitly via the #[module_lock] function attribute, blocking indirect closure-based reentrancy for sensitive functions. In the previous example, the withdraw function would be updated as follows:

    #[module_lock]
    public fun withdraw(caller: &signer, amount: u64, callback: ||()) {
        let addr = signer::address_of(caller);
        assert!(storage::get_balance(addr) - amount >= MIN_ALLOWED_BALANCE);
        callback();
        storage::set_balance(addr, storage::get_balance(addr) - amount);
    }

Indirect reentrancy (Module A → Module B → Module A) is now disallowed. However, direct closure-based reentrancy remains possible, even with the module lock explicitly enabled. This is because the lock check only triggers on cross‑module entries. Consider the below dapp snippet:

module example::victim {
    use example::storage;
    use std::debug;

    public fun register(caller: &signer, apt_callback: ||() has store+copy, usdc_callback: ||() has store+copy) {
        storage::register(caller, apt_callback, usdc_callback);
    }

    #[module_lock]
    public fun claim_rewards_apt() {
        let recipient = storage::get_winner();
        assert!(recipient != @0);
        transfer_apt(recipient, 1000);

        let callback = storage::get_apt_callback(recipient);
        callback();

        storage::set_winner(@0);
    }

    #[module_lock]
    public fun claim_rewards_usdc() {
        let recipient = storage::get_winner();
        assert!(recipient != @0);
        transfer_usdc(recipient, 1000);

        let callback = storage::get_usdc_callback(recipient);
        callback();

        storage::set_winner(@0);
    }
}
module example::attacker {
    use example::victim;
    use example::attack_helper;

    public fun attack(attacker: &signer) {
        victim::register(attacker, victim::claim_rewards_usdc, attack_helper::nop);
        victim::claim_rewards_apt();
    }
}

Despite the #[module_lock] attribute being present on both reward functions, the vulnerability remains. By registering the claim_rewards_usdc as the callback, the attacker reenters the module, claiming the same reward twice. Since the attack does not cross modules, the module lock check is bypassed. This scenario demonstrates a crucial subtlety in the inner workings of the reentrancy checker: the module lock blocks only indirect reentrancy. When direct reentrancy is a risk, developers should fall back on classic checks‑effects‑interactions patterns rather than relying solely on #[module_lock].

Conclusion

Move’s approach to reentrancy has evolved as the language has grown more powerful and expressive. While its static checks and runtime protections still prevent most reentrancy exploits, new language features such as closures introduce nuanced risks, especially when interacting with third-party resources. The #[module_lock] attribute is an important tool for developers to reinforce indirect reentrancy defences; however, it does not cover every edge case. Developers building on Aptos today should remain aware of these nuances to fully leverage Move’s security benefits and avoid unexpected pitfalls. As powerful as Move’s security model may be, any security model can become vulnerable if misused. No matter the tools employed, security will always, ultimately, remain in the hands of the developer.

Related Posts

VIEW ALL
The CPIMP Attack: an insanely far-reaching vulnerability, successfully mitigated
Tech Deep Dive

The CPIMP Attack: an insanely far-reaching vulnerability, successfully mitigated

[by the Dedaub team] A major attack on several prominent DeFi protocols over many blockchains was …

15 July 2025
The $11M Cork Protocol Hack: A Critical Lesson in Uniswap V4 Hook Security
Tech Deep Dive

The $11M Cork Protocol Hack: A Critical Lesson in Uniswap V4 Hook Security

On 28th of May 2025, Cork Protocol suffered an $11M exploit due multiple security weaknesses, …

30 May 2025
The Cetus AMM $200M Hack: How a Flawed “Overflow” Check Led to Catastrophic Loss
Tech Deep Dive

The Cetus AMM $200M Hack: How a Flawed “Overflow” Check Led to Catastrophic Loss

On May 22, 2025, the Cetus AMM on the Sui Network suffered a devastating hack resulting in over $200 …

23 May 2025