Skip to main content
Jazzy
Varun Verma

Move Fast & Break Things, Part 1: Move Security (Aptos)

Exploring the security pitfalls of the Move language (Aptos)
Article heading

Have you heard of Move yet?

The Move language is an innovation in blockchain technology that aims to make smart contract programming more secure. The blockchain ecosystem needs inherently safer tools if we ever want to extend to the mass market, given the billions of dollars lost to DeFi hacks in recent years. However, such a task is no simple feat. We explored whether Move solves this problem.

For context, the Move language sprung from Meta’s aborted Diem project in 2019. The team working on the project sought to keep it alive and thus sparked an independent movement to fulfill the vision. Two teams, Aptos & Sui, comprise the majority of the Move ecosystem today, with each team being some subset of the original Diem team.

Why are there two L1s practically trying to accomplish the same goal and using relatively the same underlying tech? Because it’s blockchain, and that’s how we do things. Let’s explore this further.

It should be noted that Sui Move works slightly different than the Move language, though the fundamental concepts remain the same. The article and its contents will focus mainly on the Aptos Move.

Section 0: What Is Move? Core Concepts

At its core, Move is a language designed specifically for blockchains. It compiles into Move bytecode and is executed on the chain. There is a verifier that verifies the bytecode for both memory safety (just like Solana) and type safety (not like Solana).

For Aptos, there are Accounts instead of direct public/private key pairs (such as in ETH/Solana). Accounts have authentication keys that can be rotated and are used to sign transactions from that Account. There may be more than one authentication key per account.

The main differentiator in Move is its linear type system. The linear type system encompasses resources. Think of a resource as a type that can never be copied or dropped, only moved between accounts and locations. Imagine them as physical objects you can move between different locations.

Each Account comprises of both resources and compiled modules. Each account can have multiple resources and modules. The official Accounts documentation covers it in a lot more in-depth.

For example, the Coin (ERC20 equivalent) in Move is a resource that is stored in the account that actually holds the Coins. Instead of single contract accounting (balances in ERC20), the Coin resources are moved around (transfer of ownership) whenever there is a transfer. This paradigm may seem simple but has vast consequences for composability, extensibility, and security.

Here are the main things you need to know about resources:

  • Each resource is managed by the module that defines it. It exerts full control and ownership over it and its properties. Only that module can assign it to other accounts.
  • If a resource is assigned to another account, that account won’t be able to perform any operations on it that aren’t exposed by the API of the defining module.

For a more in-depth introduction about Move on Aptos, you can reference the official guide here.

Section 1: Comparison to Solidity

Let’s review some of the pitfalls of Solidity and see how Move stands from a security standpoint.

Overflow / Underflow

The classic bug of them all. If you’re unfamiliar, overflow/underflow occurs when you go past the maximum or minimum your integer type is allocated for.

For example, uint8 can hold a maximum of 255, so adding 1 should overflow it to 0 (or error out). In the majority of the cases, especially on the blockchain, it is ideal to have an overflow revert instead of a loop-around to 0.

You don’t want to have 0 coins after you add 1 to 255, right?

Let’s see how it behaves in Move:

#[test]
fun test_overflow(): u8 {
let num: u8 = 255;
num = num + 1;
return num
}

VMError (if there is one): VMError {
major_status: ARITHMETIC_ERROR,
sub_status: None,
message: None,

Nice! It was caught as an “Arithmetic Error” during execution in the VM.

But there’s a caveat. Even in solidity 0.8+ with the overflow checks, there’s nothing preventing overflows in bitwise operators. Let’s see how Move deals with it:

#[test]
fun test_overflow(): u8 {
let num: u8 = 255;
num = num << 2;
return num
}

No good. 255, shifted left, overflowed but didn’t revert.

This is not a critical issue because these bitwise operators aren’t as common as your everyday arithmetic operations.

Division Precision

Similar to Solidity, we don’t have the luxury of floating point numbers. This makes our division truncate and leaves the door open for precision errors. For example, here we can see 35 divided by 6 being equal to 5 (floored down).

#[test]
public fun division_precision() {
let floored_div = 35 / 6;
debug::print<u8>(&floored_div);
}

Running Move unit tests
[debug] 5

Reentrancy

This classic bug has been plaguing Ethereum…. But does MoveVM mitigate it?

One feature Move offers is no dynamic dispatch (some caveats with generics). This essentially means that functions are resolved and linked at compile time rather than runtime. Consider the following example:

If we look at the following function in solidity, there is no way to determine what function arbitrary_call is going to call. Any address and data can be supplied:

function arbritrary_call(address addr, bytes calldata data) {
addr.call{value: 1000}(data);
}

In Move, this is not directly possible. Any external module we want to call has to be predeclared and imported (even as a stub) and cannot be dynamically computed at runtime.

So the question remains: Does this prevent reentrancy?

To a great degree, yes. There are cyclic dependency checks during the linking phase, which would catch most, if not all, reentrancies.

This code and output depicts an example of this scenario:

module PropertyTesting::callback_module {
use PropertyTesting::airdrop_module;

public fun on_token_received() {
file_4::claim_airdrop(@0x5, @0x7);
}
}

module PropertyTesting::airdrop_module {

use PropertyTesting2::callback_module;
// use std::signer::{address_of};

struct Coin has key {
value: u64
}

struct Airdrop has key {
claimed: bool
}

public fun init(receiver: &signer, bank: &signer) {
move_to<Airdrop>(bank, Airdrop {
claimed: false
});
move_to<Coin>(bank, Coin {
value: 100
});
move_to<Coin>(receiver, Coin {
value: 10
});
}

// receiver: 0x5 bank: 0x7

const ERROR_ALREADY_RECEIVED: u64 = 100;

public fun claim_airdrop(receiver: address, bank: address) acquires Coin, Airdrop {
// check if airdrop has been calimed
let claimed = &mut borrow_global_mut<Airdrop>(bank).claimed;
assert!(!*claimed, ERROR_ALREADY_RECEIVED);

// Decrease the banks balance
let value = &mut borrow_global_mut<Coin>(bank).value;
*value = *value - 10;

// Increase the user's balance
let value = &mut borrow_global_mut<Coin>(receiver).value;
*value = *value + 10;

// let user know event has happened
callback_module::on_token_received();

// don't let them claim twice... supposedly?
*claimed = true;
}

}

Essentially, it’s an airdrop contract with a callback to our callback_module.

// let user know event has happened
callback_module::on_token_received();

In theory, it shouldn’t compile and/or link because there’s a circular dependency between callback_module and airdrop_module.

Here’s the output:

It fails as expected.

It should be noted that while this looks good for Move from a security standpoint, it is also limiting, so there is a tradeoff.

Section 2: General Security

Denial of Service

Like other blockchains, computation costs gas. Thus, unbounded computation can lead to denial-of-service issues. For example, vectors that grow too large and become for-looped pose a danger. All loops should be heavily inspected to make sure they are under the gas limit.

#[test] // THIS CODE TIMESOUT!
public fun long_loop() {
let i = 1;
let n = 1000000;

while (i <= n) {
i = i + 1;
};
}

Access Control

The simplest form of access control we see in Move code is simply checking that the signer is of a specific address. For example, here’s a snippet from Aptos Core:

public fun assert_vm(account: &signer) {
assert!(signer::address_of(account) == @vm_reserved, error::permission_denied(EVM))
}

It asserts the caller of the function is a specific address.

While this is the simplest form of access control, it is pretty limited. For example, what if we want to change who has permissions to call this function?

Enter the cap pattern.

What is the cap pattern?

Cap stands for capabilities—resources owned by accounts that have the capability to perform a certain operation.

This pattern is cool because a user can transfer their capabilities to someone else if desired.

For example, the following code shows how capabilities work and can be parameterized on types to create more sophisticated forms of access control.

module PropertyTesting::cap_tester {
use std::signer::{address_of};
struct Type1 has key {}
struct Type2 has key {}
struct Capability<phantom TYPE> has key{}

public fun cap_1_need(_cap: &Capability<Type1>) {}

public fun cap_2_need(_cap: &Capability<Type2>) {}

public fun get_cap_type1(person_1: &signer) {
let cap_type_1 = Capability<Type1> {
};
move_to<Capability<Type1>>(person_1, cap_type_1);
}
public fun get_cap_type2(person_1: &signer) {
let cap_type_2 = Capability<Type2> {
};
move_to<Capability<Type2>>(person_1, cap_type_2);
}
#[test(person_1 = @0x100)]
public fun test_cap_1(person_1: &signer) acquires Capability {
get_cap_type1(person_1);
let cap: &Capability<Type1> = borrow_global<Capability<Type1>>(address_of(person_1));
cap_1_need(cap);
}

}

Capabilities work because only the module that declares the resource can assign it to an account. The resource is completely unique to the module, and no other module can create the same resource, even if they give it the same name. Under the hood, this is because resource identifiers are linked to the module that created them.

Here’s an image from the Aptos White Paper that shows how resource identifiers are derived from the module that declared it; for example, the coin resource name starts at 0x3, which is the address of the package.

Point being, if you prove that you have ownership of a reference to a resource, that is sufficient enough to prove some sort of identity as the module itself can restrict who can acquire that resource (e.g., the module could be programmed to only assign the resource to a signer in the constructor or other authorized addresses).

Our specific example shows an even more nuanced use case because not only is the user getting a capability upon calling get_cap_1 or get_cap_2, but they are also getting it genericised on whatever type they supply, which is enforced at compile time.

This means that the function cap_1_need cannot be called by someone who passes in a reference Capability since there is an added layer of enforcement on the type the capability acquires.

Bonus: Resources Caveat

As part of our research into Aptos, we came across an interesting design pattern in which some resource of value is wrapped by some sort of holder. Here’s an example in Coin.move.

// Main structure representing a coin/token in an account's custody.
struct Coin<phantom CoinType> has store {
/// Amount of coin this address has.
value: u64,
}

// A holder of specific coin types and associated event handles.
// These are kept in a single resource to ensure locality of data.
struct CoinStore<phantom CoinType> has key {
coin: Coin<CoinType>,
frozen: bool,
deposit_events: EventHandle<DepositEvent>,
withdraw_events: EventHandle<WithdrawEvent>,
}

This is because Coin has the type ability of store, meaning it can’t be stored in global storage directly. The CoinStore on the other hand has key, allowing it to be stored at top level on global storage.

We can see CoinStore wraps Coin along with a bit of metadata. This is a common design pattern to add additional information such as Events to a resource. You can think of Coin as real money and CoinStore as your wallet that holds the money.

This is all good, but things get a bit trickier when resources need to be passed around from other modules. The problem is that a resource can only be manipulated via the API exposed by its defining module.

However, if an external module returns a resource, we can wrap it in our wrapper resource. While we won’t be able to modify it directly as it was not declared in our module, we can still get a reference to it. This is how capabilities can be stored by external accounts while still maintaining a borrowable reference to them

Going back to the Coin module, we found this property may be an hinderance to freezing/burning of coins.

public fun burn_from<CoinType>(
account_addr: address,
amount: u64,
burn_cap: &BurnCapability<CoinType>,
) acquires CoinInfo, CoinStore {
// Skip burning if amount is zero. This shouldn't error out as it's called as part of transaction fee burning.
if (amount == 0) {
return
};

let coin_store = borrow_global_mut<CoinStore<CoinType>>(account_addr);
let coin_to_burn = extract(&mut coin_store.coin, amount);
burn(coin_to_burn, burn_cap);
}

As we can see, the coins burned are extracted from CoinStore.

What if the user decides to not hold their coins in the CoinStore? Their coins would be unfreezable/unburnable.

An account could define their own Coin wrapper

struct NotCoinStore<phantom CoinType> has key{
coin: Coin<CoinType>
}

and essentially store the Coins in NotCoinStore. This will break the majority of the functions in coin.move that essentially expect the Coins to be stored in a CoinStore, including burning and freezing.

This could lead to Coins being inaccessible for the coin module as it is not able to borrow a reference to NotCoinStore. Here’s a small test case to verify the issue:

module PropertyTesting::file_2 {
use PropertyTesting::file_1::{Self, USDC};
// use aptos_std
// use aptos_std::debug;
use std::signer::{Self, address_of};
use aptos_framework::coin::{Self, Coin, balance};

struct NotCoinStore<phantom CoinType> has key{
coin: Coin<CoinType>
}

#[test(signer = @PropertyTesting, random_account = @0x69)]
#[expected_failure(abort_code = 0x5000A)] //Fails because frozen
public fun coins_can_be_frozen(signer: &signer, random_account: &signer) {
aptos_framework::account::create_account_for_test(signer::address_of(signer));
aptos_framework::account::create_account_for_test(signer::address_of(random_account));

coin::register<USDC>(signer);
coin::register<USDC>(random_account);

let coins = file_1::generate_coin(signer);
coin::deposit<USDC>(signer::address_of(signer), coins);

coin::transfer<USDC>(signer, signer::address_of(random_account), 1000);
assert!(balance<USDC>(signer::address_of(random_account)) == 1000, 1);

file_1::freeze_coins(signer::address_of(signer));

coin::transfer<USDC>(signer, signer::address_of(random_account), 1000);
}

#[test(signer = @PropertyTesting, random_account = @0x69)]
public fun unfreezable_coins(signer: &signer, random_account: &signer) acquires NotCoinStore {
aptos_framework::account::create_account_for_test(signer::address_of(signer));
aptos_framework::account::create_account_for_test(signer::address_of(random_account));

coin::register<USDC>(signer);
coin::register<USDC>(random_account);

let coins = file_1::generate_coin(signer);

let coin_holder = NotCoinStore {
coin: coins
};

move_to(signer, coin_holder);

//coin::transfer<USDC>(signer, signer::address_of(random_account), 1000);
transfer<USDC>(signer, signer::address_of(random_account), 1000);
assert!(balance<USDC>(signer::address_of(random_account)) == 1000, 1);

file_1::freeze_coins(signer::address_of(signer));

transfer<USDC>(signer, signer::address_of(random_account), 1000);
assert!(balance<USDC>(signer::address_of(random_account)) == 2000, 1);
}

public fun transfer<CoinType>(from: &signer, to: address, amount: u64) acquires NotCoinStore {
let coins = &mut borrow_global_mut<NotCoinStore<USDC>>(signer::address_of(from)).coin;
let real_coins = coin::extract(coins, amount);

coin::deposit<USDC>(to, real_coins);
}
}

module PropertyTesting::file_1 {

use aptos_framework::coin::{Coin};
use std::signer;
use std::string::{utf8};
// use aptos_framework::account;

struct Authorities<phantom CoinType> has key {
burn_cap: BurnCapability<CoinType>,
freeze_cap: FreezeCapability<CoinType>,
mint_cap: MintCapability<CoinType>,
}
use aptos_framework::coin::{Self, MintCapability, BurnCapability, FreezeCapability};

struct USDC has key {
}


public fun generate_coin(signer_1: &signer): Coin<USDC> acquires Authorities {
let (burn_cap, freeze_cap, mint_cap) = coin::initialize<USDC>(signer_1, utf8(b"hey"), utf8(b"b"), 8, false);
let authorities = Authorities {
burn_cap,
freeze_cap,
mint_cap
};

move_to<Authorities<USDC>>(signer_1, authorities);
let mint_cap_ref = &borrow_global<Authorities<USDC>>(signer::address_of(signer_1)).mint_cap;
let ten_thousand_coins: Coin<USDC> = coin::mint<USDC>(10000, mint_cap_ref);
ten_thousand_coins
}

public fun freeze_coins(person_1: address) acquires Authorities {
let freeze_cap = &borrow_global<Authorities<USDC>>(person_1).freeze_cap;
coin::freeze_coin_store<USDC>(person_1, freeze_cap);
}

public fun burn_coins(person_1: address) acquires Authorities {
let burn_cap = &borrow_global<Authorities<USDC>>(person_1).burn_cap;
coin::burn_from<USDC>(person_1, 10000, burn_cap);
}

}

What’s happening here? PropertyTesting::File_1 is initializing the coin and giving the capabilities to the respective signer.

PropertyTesting::File_2 is where we can see the inhibiting of the burning and freezing.

Let’s look at test case coins_can_be_frozen. The user sends coins to a random account and it works, which is depicted by the assertion statements checking the balances. Then the freezing capability is used to freeze transferring, so the user’s attempts to transfer fail.

#[test(signer = @PropertyTesting, random_account = @0x69)]
#[expected_failure(abort_code = 0x5000A)] //Fails because frozen
public fun coins_can_be_frozen(signer: &signer, random_account: &signer) {
aptos_framework::account::create_account_for_test(signer::address_of(signer));
aptos_framework::account::create_account_for_test(signer::address_of(random_account));

coin::register<USDC>(signer);
coin::register<USDC>(random_account);

let coins = file_1::generate_coin(signer);
coin::deposit<USDC>(signer::address_of(signer), coins);

coin::transfer<USDC>(signer, signer::address_of(random_account), 1000);
assert!(balance<USDC>(signer::address_of(random_account)) == 1000, 1);

file_1::freeze_coins(signer::address_of(signer));

coin::transfer<USDC>(signer, signer::address_of(random_account), 1000);
}

All is working as expected.

Yet in the test case unfreezable_coins, the user generates coins and holds them in their own resource, NotCoinStore, and not the default one given by the Coin Module, CoinStore.

#[test(signer = @PropertyTesting, random_account = @0x69)]
public fun unfreezable_coins(signer: &signer, random_account: &signer) acquires NotCoinStore {
aptos_framework::account::create_account_for_test(signer::address_of(signer));
aptos_framework::account::create_account_for_test(signer::address_of(random_account));

coin::register<USDC>(signer);
coin::register<USDC>(random_account);

let coins = file_1::generate_coin(signer);

let coin_holder = NotCoinStore {
coin: coins
};

move_to(signer, coin_holder);

//coin::transfer<USDC>(signer, signer::address_of(random_account), 1000);
transfer<USDC>(signer, signer::address_of(random_account), 1000);
assert!(balance<USDC>(signer::address_of(random_account)) == 1000, 1);

file_1::freeze_coins(signer::address_of(signer));

transfer<USDC>(signer, signer::address_of(random_account), 1000);
assert!(balance<USDC>(signer::address_of(random_account)) == 2000, 1);
}

They can still perform coin operations such as transferring by using the function below:

public fun transfer<CoinType>(from: &signer, to: address, amount: u64) acquires NotCoinStore {
let coins = &mut borrow_global_mut<NotCoinStore<USDC>>(signer::address_of(from)).coin;
let real_coins = coin::extract(coins, amount);

coin::deposit<USDC>(to, real_coins);
}

They are not impacted by freezing. As seen in this test case, the random_account balance has still gone up, even after the freezing, rendering it useless.

The example can be easily adjusted to have unburnable coins, in which the user holds their coins not in the default CoinStore and thus cannot have their tokens burned.

For stablecoins such as USDC and USDT, freezing assets is an important regulatory measure. We will see how the ecosystem evolves to deal with such issues.

It should be noted that practically, the effect might be similar to wrapped assets on ETH/SOL in a sense that their backing vaults also cannot be frozen.

Conclusion

In our investigation so far, we’ve noticed Move has many security properties that make it a safe and useful programming language for smart contracts. It’s evident that the design of the language was specifically intended to mitigate some of the pitfalls we have observed in other blockchain languages. It has defined a whole new pattern of creating and managing digital assets. Nevertheless, there are still some elementary concerns that likely need to be dealt with. Overall, we are really excited to see how the ecosystem matures and what design patterns are adopted by developers.

In this upcoming series, we will also be looking into the Move prover. It formally verifies smart contracts, the ultimate safety guarantee. We will also explore other design patterns in Aptos Core, so stay tuned!

Do you want to ship your Move contracts without an audit? Hopefully not. If you want to catch coding mistakes, design flaws and economic issues, fill out our contact form. We’d be happy to help.

About Us

Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.

Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.

Contact us for an audit that’s better than the rest. Real audits, not rubber stamps.