Skip to main content
Junyi

Move Fast & Break Things, Part 2: A Sui Security Primer

Exploring the security pitfalls of the Move language in Sui compared to Aptos
Article heading

This blog post provides a gentle introduction to Sui, a new Move-based blockchain. We compare Sui with Aptos and highlight some important differences for deveopers to know, especially for secure smart contract development.

Introduction

Sui is a proof-of-stake smart contract blockchain platform targeting the Move language. Sui is unique in that each object has its own ledger and transactions don’t necessarily have to be in sequence. This is claimed to be a breakthrough optimization and has important implications on object handling on the Sui blockchain.

The Move language used on the Sui blockchain was originally developed at Facebook as part of the Libra blockchain (later Diem). That project was discontinued, and the team that made it split into two groups making Sui and Aptos, both layer 1 blockchains running Move. There are a few other blockchains also building on Move.

Sui is intended to allow real-time use cases of digital assets, such as items in a game. The transaction needs to be able to happen quickly enough for a reasonable playing experience.

On the technical side, the validators and client are written in Rust, with governance partially written in Move on-chain. There is also a Sui Move standard library pre-deployed on-chain, which smart contracts can invoke. A limited version of the original Move standard library is also available.

To help explain Sui, we’ll compare Sui Move to Aptos Move and core Move.

Sui Object Model

Accounts

It’s common for blockchains (and Aptos) to use accounts to manage matters of permission and ownership. Accounts are essentially addresses. Accounts are associated with private key(s), which can be used to sign transactions. Resources or objects, as they are called in Sui, are for the most part held at an account.

For example, holding a Coin resource at your account on Sui would allow you to sign transactions from that account using the SUI(currency) out of that Coin to pay for the gas cost of running some function.

For a more in-depth discussion of resources and accounts, and how it works on Aptos, check out our blog post on Aptos.

Objects

Objects in core, Aptos, and Sui Move are used to represent assets like USDT, the administrator permissions to a smart contract, chips in an on-chain poker game, and any other data that the smart contract is tracking. Needless to say, objects and how they are managed are central to Move smart contracts, whether on Aptos or Sui.

An object at its core is just a collection of related data represented as a struct in Move. A Move struct can have four types of abilities: key, store, copy, and drop.

Abilities

  • key makes an object able to be stored on-chain. If an object does not have key, it has to be stored under another object or destroyed before the contract finishes execution.
  • store allows an object to be stored under another object. Think of structs like boxes, and store allows this particular box to fit inside other boxes.
  • copy allows an object to be copied. Non-copyable objects can’t be copied, but manual “copies” can theoretically still be created by the creating contract.
  • drop allows an object to be quietly destroyed. Simply allowing the object to go out of scope will destroy it.

On Sui, in order for a Move object to be stored on-chain under an account, it must be also be a Sui object. Sui objects have the key ability (since they are stored on-chain) and a special first member of the struct named id with type UID. A bytecode verifier ensures that any struct with the key ability has the special first member.

Object IDs are implemented with UIDs in Sui Move. In Sui, UIDs are unique and are used to reference objects both from on-chain smart contracts and off-chain in the Sui client. In Sui, to pass an object into a Move contract, you must refer to the object by its UID.

The UID is intended to be unique per on-chain object. The UID can be used in either a RPC call or the Sui command line. The UID can also be used to request information about the state of the object on-chain.

The most significant difference between Sui Move and core/Aptos Move is that Sui does not leverage the Move global storage.

In Aptos/core Move, any object with the key ability can be stored at an account (does not need UID). An Aptos smart contract can access object of types that it defines under any account at any point in its execution, even if the caller is not the account that is accessed. This is typically done through borrow_global, borrowing a reference to an object type it defines.

Whereas in the case of Sui, objects are passed to the runtime directly at the top level call and no external references can be borrowed during execution. To store objects, you use the transfer::transfer(object, address) function. A complete example is available in the UID swapping section of this article.

In Sui, all objects a smart contract can access are passed in from outside Move. This is similar to Solana’s predeclared accessed accounts per transaction. Only the owner of the account can pass objects under that account into Move (directly or indirectly, by reference). Objects stored under other accounts other than the sender are therefore inaccessible (except shared and frozen objects). The ownership of objects is central in determining who can do what. The owner of a USDTCoin can spend it. The owner of the WithdrawPermission can withdraw money from a flash lender.

Sui objects can also be in some other special states for situations beyond simple ownership by account. At any point in Sui Move execution, a Sui object can be in one of six states.

Sui object states

  • Inside a Move contract
    • The Sui object was either passed by value into a Move contract or was created inside the Move contract. It must be transferred to some account (if it has key) or destroyed before the end of Move execution.
  • Frozen/immutable (global read-only)
    • The Sui object can be passed by read-only reference into Move contracts by anyone.
  • Shared (global read-write)
    • The Sui object can be passed by read-write reference into Move contracts by anyone.
  • Owned by an address
    • Only that address may pass in the object into Move (as reference or directly).
    • In core Move, the declaring contract can use types it declares from any address regardless of caller, whereas in Sui only the caller’s owned objects may be passed into Move.
    • In core Move only one object of a type can be stored at an address, but there is no such restriction in Sui.
  • Owned by another object
    • This is not implemented as of the time of writing (November 2022), and the mechanics are unclear.
    • According to the current documentation, the object would still be accessible directly on-chain.
  • Wrapped
    • The object has both key and store and is stored under (directly as a member or nested under multiple levels of objects) another object that is stored on-chain somewhere.
    • The object isn’t directly accessible on-chain. The object is stored in another wrapper object. To recover the original object, you must destroy the wrapper object (unwrapping).

Object IDs

Object UIDs are supposed to be globally unique. They are created through object::new(&mut TxContext) and destroyed through object::delete(UID).

Other Sui differences

TxContext

In Aptos, the signer of a transaction is directly received as an argument passed into the function that was called from outside of Move. In Sui the TxContext type can be declared as an argument of a function just like you would with signer, and the signer can be obtained from TxContext using a library function. Also, TxContext is used to create UIDs in Sui Move. As of this writing, TxContext is only used for those purposes.

Global storage and the acquires keyword

Aptos uses the global storage operators (move_to, move_from, etc.) from core Move. Functions that use global storage must have the appropriate acquires annotation.

Unsurprisingly, acquires keyword is not used in Sui, along with the global storage operators since Sui does not use the Move global storage.

The init(&mut TxContext) function

Aptos supports an initialization function called upon module deploy with a similar signature. Sui will call the init function declared with this signature upon module deploy.

Interesting behavior and potential vulnerabilities

Object ownership transfer

Sui objects (with key+store) stored at an account can be freely transfered between accounts without involvement of the creating contract. This could potentially be unintuitive and leads to problems in contracts that enforce invariants through an account holding some combination of contract-created objects. Objects with only the key ability are more restricted. They can only be transferred by the creating module.

Object freezing

Sui objects stored at an account can be frozen without the involvement of the creating contract if that object also has store. A frozen object is global and immutable. It can be passed to the smart contract by anyone, which may be surprising to developers who expect the object to only come from a single account.

Reentrancy

In Move, Reentrancy isn’t possible since dynamic callbacks are not possible. This is a classic bug in Ethereum/Solidity contracts where a smart contract calls a callback function that calls back into the contract again, potentially operating on a partially updated state of that smart contract. The function call hasn’t finished at this point; it is waiting on callback to finish. You can find more details at this Consensys article.

Integer overflows

In Move, integer overflows are automatically reverted. Any transaction that causes an integer overflow cannot succeed. However, bitwise operators cannot overflow, even if the end result seems unintuitive. For example, bitshifting outside the variable bit width is allowed, and the shifted-in bits are zero. We discuss this in our Aptos Move article.

Sui object UID swapping

You may know that UIDs are supposed to uniquely identify objects on the chain. However, if you control the contract that created two objects, and can therefore destruct/recreate them, you can change the type of an object on-chain.

struct Cat has key {
id: UID,
}

struct Dog has key {
id: UID,
}

public entry fun transmute(cat: Cat, dog: Dog, ctx: &mut TxContext) {
let Cat {
id: cat_id,
} = cat;
let Dog {
id: dog_id,
} = dog;
let new_cat = Cat {
id: dog_id,
};
let new_dog = Dog {
id: cat_id,
};
transfer::transfer(new_cat, tx_context::sender(ctx));
transfer::transfer(new_dog, tx_context::sender(ctx));
}

Let’s try this contract on-chain.

First we check the types of our cat and our dog:

$ sui client object --id 0x1ae0eb5f3b972449ff2a4134e799f447c662939e

----- Move Object (0x1ae0eb5f3b972449ff2a4134e799f447c662939e[1]) -----
Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
Version: 1
Storage Rebate: 12
Previous Transaction: DfF7MGmkZcl4YuLT7gnLXsyfmZhNv/d/72V9L0eOBc0=
----- Data -----
type: 0xccfefb2987fabb577cb4e5cb726d7d3a08c7f2e2::objid::Cat
id: 0x1ae0eb5f3b972449ff2a4134e799f447c662939e

$ sui client object --id 0x49dc71ac8bb7b649c8ff21d255295da72d791707

----- Move Object (0x49dc71ac8bb7b649c8ff21d255295da72d791707[1]) -----
Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
Version: 1
Storage Rebate: 12
Previous Transaction: DfF7MGmkZcl4YuLT7gnLXsyfmZhNv/d/72V9L0eOBc0=
----- Data -----
type: 0xccfefb2987fabb577cb4e5cb726d7d3a08c7f2e2::objid::Dog
id: 0x49dc71ac8bb7b649c8ff21d255295da72d791707

Then we call transmute(cat, dog)

$ sui client call --package 0xccfefb2987fabb577cb4e5cb726d7d3a08c7f2e2 --module objid --gas-budget 10000 --function transmute --args 0x1ae0eb5f3b972449ff2a4134e799f447c662939e 0x49dc71ac8bb7b649c8ff21d255295da72d791707

----- Certificate ----
<snip>
----- Transaction Effects ----
Status : Success
Mutated Objects:
- ID: 0x1ae0eb5f3b972449ff2a4134e799f447c662939e , Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
- ID: 0x37d38af0f4cac30679658c3a75cbbf90169d82d7 , Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
- ID: 0x49dc71ac8bb7b649c8ff21d255295da72d791707 , Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )

and check the types of our cat and dog again:

$ sui client object --id 0x1ae0eb5f3b972449ff2a4134e799f447c662939e

----- Move Object (0x1ae0eb5f3b972449ff2a4134e799f447c662939e[2]) -----
Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
Version: 2
Storage Rebate: 12
Previous Transaction: kTbgQmSch/kav+EW2abSfM3w4Seujv3UK7Yn4ZUV34Q=
----- Data -----
type: 0xccfefb2987fabb577cb4e5cb726d7d3a08c7f2e2::objid::Dog
id: 0x1ae0eb5f3b972449ff2a4134e799f447c662939e

$ sui client object --id 0x49dc71ac8bb7b649c8ff21d255295da72d791707

----- Move Object (0x49dc71ac8bb7b649c8ff21d255295da72d791707[2]) -----
Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
Version: 2
Storage Rebate: 12
Previous Transaction: kTbgQmSch/kav+EW2abSfM3w4Seujv3UK7Yn4ZUV34Q=
----- Data -----
type: 0xccfefb2987fabb577cb4e5cb726d7d3a08c7f2e2::objid::Cat
id: 0x49dc71ac8bb7b649c8ff21d255295da72d791707

This can’t really be used to attack anything on-chain except in very unusual circumstances. Some contract would have to refer to a foreign object by UID and make assumptions about its type in such a way that changing it would violate the assumptions.

However, this can potentially be a problem for off-chain applications, which assume the type of objects on-chain.

Hiding and restoring objects with contract control

Normally, it’s possible to move a Sui object stored at an account into another object if that Sui object has store. However, if you have control over the contract that created the object, you can destruct it and store the UID inside another object, even if that object does not have store.

struct Cat has key { // notice: no `store` capability!
id: UID,
}

struct Tomb has key {
id: UID,
cat_id: UID,
}

public entry fun entomb(cat: Cat, ctx: &mut TxContext) {
let Cat { // destruct Cat to extract its UID
id: cat_id
} = cat;
let tomb = Tomb {
id: object::new(ctx),
cat_id: cat_id,
};
transfer::transfer(tomb, tx_context::sender(ctx));
}

public entry fun resurrect(tomb: Tomb, ctx: &mut TxContext) {
let Tomb { // get the cat UID out of the tomb, destructing the tomb in the process
id: tomb_id,
cat_id: cat_id,
} = tomb;
object::delete(tomb_id);
let cat = Cat { // create a new cat with the same UID as before
id: cat_id,
};
transfer::transfer(cat, tx_context::sender(ctx));
}

Let’s try this with the cat from before. Let’s take a look at the cat:

$ sui client object --id 0x49dc71ac8bb7b649c8ff21d255295da72d791707

----- Move Object (0x49dc71ac8bb7b649c8ff21d255295da72d791707[2]) -----
Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
Version: 2
Storage Rebate: 12
Previous Transaction: kTbgQmSch/kav+EW2abSfM3w4Seujv3UK7Yn4ZUV34Q=
----- Data -----
type: 0xccfefb2987fabb577cb4e5cb726d7d3a08c7f2e2::objid::Cat
id: 0x49dc71ac8bb7b649c8ff21d255295da72d791707

Now let’s hide away the cat with entomb(cat):

$ sui client call --package 0xccfefb2987fabb577cb4e5cb726d7d3a08c7f2e2 --module objid --gas-budget 10000 --function entomb --args 0x49dc71ac8bb7b649c8ff21d255295da72d791707

----- Certificate ----
<snip>
----- Transaction Effects ----
Status : Success
Created Objects:
- ID: 0x342fdc3c73baba9d4c7717ff899ef74c3fe73833 , Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
Mutated Objects:
- ID: 0x37d38af0f4cac30679658c3a75cbbf90169d82d7 , Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
Wrapped Objects:
- ID: 0x49dc71ac8bb7b649c8ff21d255295da72d791707

Notice how our cat is considered a wrapped object now. We’ll check that the cat can no longer be accessed by its ID:

$ sui client object --id 0x49dc71ac8bb7b649c8ff21d255295da72d791707
Object deleted at reference (0x49dc71ac8bb7b649c8ff21d255295da72d791707, SequenceNumber(3), o#5858585858585858585858585858585858585858585858585858585858585858).

Now we bring the cat back into existence with resurrect(tomb):

$ sui client call --package 0xccfefb2987fabb577cb4e5cb726d7d3a08c7f2e2 --module objid --gas-budget 10000 --function resurrect --args 0x342fdc3c73baba9d4c7717ff899ef74c3fe73833

----- Certificate ----
<snip>
----- Transaction Effects ----
Status : Success
Mutated Objects:
- ID: 0x37d38af0f4cac30679658c3a75cbbf90169d82d7 , Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
Deleted Objects:
- ID: 0x342fdc3c73baba9d4c7717ff899ef74c3fe73833
Unwrapped Objects:
- ID: 0x49dc71ac8bb7b649c8ff21d255295da72d791707 , Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6

Let’s take a look at the cat:

$ sui client object --id 0x49dc71ac8bb7b649c8ff21d255295da72d791707

----- Move Object (0x49dc71ac8bb7b649c8ff21d255295da72d791707[4]) -----
Owner: Account Address ( 0xf302c0f8afe0d7da1f33d27b87da88f1f48b78c6 )
Version: 4
Storage Rebate: 12
Previous Transaction: /u9PrwRWlpM2S31y59iyIEMKHa1/GBNeo5s/b6olZzE=
----- Data -----
type: 0xccfefb2987fabb577cb4e5cb726d7d3a08c7f2e2::objid::Cat
id: 0x49dc71ac8bb7b649c8ff21d255295da72d791707

The security implications of this are very similar to UID swapping. On-chain programs are unlikely to be vulnerable, but off-chain programs can potentially be confused by objects going in and out of existence.

Update (1st December, 2022): Both UID swapping and object hiding were actually possible due to a bug in the Sui bytecode verifier. It was patched 30 minutes after this post was published in this commit. We applaud the Mysten Labs team for their prompt response.

Conclusion

Sui Move is a new chain still in rapid development but already has breakthrough performance improvements and a well-designed on-chain language. Sui Move has few unintuitive behaviors that could lead to vulnerabilities. As auditors and builders of the wider crypto ecosystem, we will watch Sui with anticipation.