MoveBit

Jul 07, 2023

Sui Objects-Security Principles and Best Practices

Introduction

Sui Move introduced Object, which introduced some changes in the security model of Move type, but potentially also brought new risks. This article will analyze the security model of Sui Move and provide some considerations for developers to avoid security issues

SO

The Security Model of Move:

As we know, Sui Move is a “dialect” of the Move programming language, so before we delve into the unique security model of Sui Move, it is best to first understand the security model of the original Move language to better comprehend the succeeding content. The security model of Sui Move mainly includes the following aspects:

Struct Visibility

In Move, the structure is a custom type defined using the struct keyword.

Unlike C/C++, structures in Move are module private, in the sense that their members/fields can only be accessed in the module that defines them, even though the structs are always public. This design essentially made every structs “opaque” objects to the outside worlds and other modules can only operate on them with methods that the author has defined. It helps prevent coupling between modules, maintains modularity and independence of the code, and makes it easier to achieve information isolation and functional separation between modules.

In addition, there is another feature of structures in Move, which is that they can be used to define resource types. Resource type is a special type in Move used to represent ownership of resources.

By defining resources as structure types, it is possible to make the behavior of the object types more clear and controllable, and the state of the resource can be accessed and modified through the members of the structure. This design enables Move to better support the development of blockchain applications because resource types can be used to represent important data such as digital assets and contracts on the blockchain.

Here’s a specific example: Let’s assume that on the blockchain, there is a type of digital asset called Token, which is used to represent a digital asset. This Token type can be defined as a structure with members for the Token’s value and owner, which is the address of the token’s owner. In Move, this structure could be defined in the following way:

module Token {
    struct Token {
        id: guid::UID,
        value: u64,
        ...
    }
}

In the above code, the Token structure is defined as a module-private type that can only be accessed and modified within the Token module. This means that the members of the Token structure cannot be directly accessed in other modules.

This design ensures the privacy of the token type, preventing third parties from directly accessing and modifying the token’s value and other information.

Ability of Struct

Move’s type system introduces the concept of Abilities to represent the behavior of types. In Move, each type has one or more Abilities that define whether the values of that type can be copied, moved, used as keys, or stored.

Here are some specific details about several Abilities in Move:

  • copy: The value of this type can be copied. Usually, basic types (such as integers and booleans) have copy ability, while more complex types (such as structures and resources) need to be considered on a case-by-case basis.

    For example, in the above example, Token is an asset type that should not be duplicated, so it should not have copy ability. However, for some structures that are used solely to represent data, such as a u64 id wrapper struct, it can have copy ability.

  • drop: The value of this type can be automatically destroyed at the end of the scope. The drop ability is usually used for resource types. Types with drop ability will have their values automatically destroyed at the end of their scope.

    For types without drop ability, their values will not be automatically destroyed at the end of their scope, thus causing a compilation error. The module that owns the type should provide a manual destruction method or the resource can be moved elsewhere. In the case of Token, it should not be automatically destroyed, so it should not have drop Ability.

  • key: In the original Move, the values in the global storage must have key ability, indicating that the value can be used as the key for global storage.

    Formally, the global storage in Move is a mapping of address A x type T -> value V, where type T must have key ability. However, in Sui Move, there have been some improvements to the use of global storage, so the role of Key ability is slightly different, read the sections below for detailed information.

  • store: Refers to the ability of a type’s value to be stored ( for example, in another struct).

The Security Model of Sui Object

In original Move, there’s a global storage where you can store and access structs with key ability with its type and an address as index, via builtin functions move_to<T>(addr: address, value: T), move_from<T>(addr: address) -> T, etc.

In contrast, in Sui Move, we no longer have these concepts.

As mentioned earlier, the key ability in the original Move is used to indicate that a type’s value can be used as the key for global storage. Because in Sui Move, there isn’t a global storage. So the role of the key ability has changed. Simply put, in Sui Move, the key ability indicates that values of this type can be held in global storage and requires that the first field of the type is a id: sui::object::UID, which holds the object’s key in storage. This is done because Sui Move extends the bytecode verifier in Move, which is explained in more detail below.

Extensions of the verifier in Move

The extensions of the verifier in Move primarily include the following types of checks:

  • Check of structs with key ability:
    As mentioned above, if a struct has key ability, it is considered a Sui Object. The Move verifier will check if the first field of all structs with key ability is named “id” and its type is sui::object::UID.

  • Check of global storage:
    Check of global storage: As mentioned earlier, the original Move global storage model is not applicable in Sui Move. Therefore, the Move verifier will check whether all modules have used global storage. If used, an error will be reported.”

  • ID leak check: ID leak verifier ensures that only “fresh” IDs (e.g. generated by sui::object::new) are used to fill an object’s id: UID field. Attempting to re-use an existing object’s field when creating a fresh object counts as an “ID Leak”, and will fail this verification pass. Since unpacking is the only bytecode that can extract the ID field from a Sui object. The flow of this value can be traced from this instruction to ensure that it never leaks outside the function. There are four possible ways in which it may occur:

    a. The ID is returned;

    b. Written to a mutable reference;

    c. Added to a vector;

    d. Passed as an argument to a function.

    These cases will be checked by the verifier, and an error will be reported if ID leak is found.

  • Generic parameter privacy check: There are two categories of transfer functions in Sui: private ones such as sui::transfer::transfer and their public variants (i.e., sui::transfer::public_transfer). For the private ones, the parameter T must be defined in the same module where the call initiation occurs, and the compiler requires T to have key ability. For the public ones, T can be defined in different modules and must have the key & store abilities.

  • Entry check: Check the validity of module initialization and transaction entry points.

    ► For module initialization functions:

    ☐ The existence of the function is optional. In other words, a module can have no initialization function.

    ☐ If there is one, the function must have the name init and must be private.

    ☐ The function can have up to two parameters: an optional one-time witness parameter and a required TxContext reference parameter.

    ► For transaction entry points:

    ☐ The function must have an entry attribute.

    ☐ It can have a TxContext reference parameter, and it must be the last parameter.

    ☐ The function cannot have any return values.

  • One-time witness check: If a module defines a struct type with the same name as the module name in uppercase, this struct type is considered a one-time witness. The struct type must meet the following conditions:

    ☐ Its ability can only be drop.

    ☐ It cannot be generic, that is, it does not involve type parameters.

    ☐ Its unique instance is passed as a parameter to the module initialization function.

    ☐ It is not instantiated anywhere in its defining module.

    ☐ At source level, the compiler will generate a boolean field for empty struct definition. But at the bytecode level, the verifier cannot presume that it is nonempty. So it checks that the struct has only one field of any named Boolean type.

The Ownership Model of Sui Object

All Sui Objects have an owner, and there are four ownership relationships:

  • Owned by an address: When an object is created, an address can be specified, and the object will be transferred to that address. When using an object as a parameter, regardless of whether the object is passed by value or by mutable or immutable reference, the signatures of all addresses need to be available. This ensures that only the object owner can modify the object.

  • Owned by an object: The owner of a Sui Object can also be another object. However, please note that this is different from a struct containing another struct. For more detailed information, please refer to the Sui official documentation.

  • Immutable Object: Immutable objects have no owner and cannot be modified. Therefore, only immutable references can be used to pass them as parameters. A common example is the Sui Move package, which cannot be modified after publication.

  • Shared Object: Unlike exclusive (address or object) objects, shared objects can be accessed by anyone on the network.

    The extended features and accessibility of such objects need to ensure their security by designing corresponding check mechanisms. Therefore, developers need to carefully consider the design of such objects. You can refer to the use cases in Sui Examples to understand how to use shared objects.

Common Patterns to Watch out when Programming in Sui Move

Whether you are building DeFi or other types of protocols on Sui, it is essential to conduct rigorous security audits and comprehensive code testing during the development process or before launching on the Mainnet. The following are some common patterns for developers to keep in mind.

  • Avoid giving excessive abilities to structs

    When creating custom structs using Move, it is advisable to avoid using overly generic abilities. In other words, we should consider the object’s usage scenario in advance and assign it “minimal” abilities to prevent unexpected operations.

    Excessive abilities to event struct

    According to the parameter ability requirements in sui::event, only copy and drop abilities are necessary.

    module sui::event {
        public native fun emit<T: copy + drop>(event: T);
    }

    Let’s take a look at the following struct, which is used as a parameter in events to store some key information. Therefore, the store ability is redundant in this context.

      /// Emitted when a new pool is created
      struct PoolCreated has copy, store, drop {
          /// object ID of the newly created pool
          pool_id: ID,
          token_x_name: TypeName,
          token_y_name: TypeName,
      }
    
    /// Emit the PoolCreated event
    event::emit(PoolCreated { … });

    Suggestion: By removing the store ability, we can bring the struct in line with best practices.

    /// Emitted when a new pool is created
    struct PoolCreated has copy, drop {
        /// object ID of the newly created pool
        pool_id: ID,
        token_x_name: TypeName,
        token_y_name: TypeName,
    }

    Excessive abilities to flashloan struct

    The following code snippet is from a smart contract implementing a flash loan feature. The loan method allows users to borrow a large amount of assets without collateral, while the repay method allows users to repay the loan. The implementation principle of flash loans in Sui involves defining a struct with no abilities. This struct object is created in one method and destroyed in another method, and this process must be completed within the same transaction.

    In the code below, we can see that the Receipt struct is mistakenly given the abilities of store and drop. This error allows the repay method to be bypassed, and as a result, the assets borrowed by the user may never be returned.

    /// A shared object offering flash loans to any buyer willing to pay `fee`.
    struct FlashLender<phantom T> has key {
        id: UID,
        /// Coins available to be lent to prospective borrowers
        to_lend: Balance<T>,
        /// Number of `Coin<T>`'s that will be charged for the loan.
        /// In practice, this would probably be a percentage, but
        /// we use a flat fee here for simplicity.
        fee: u64,
    }
    
    
    struct Receipt<phantom T> has store, drop {
        /// ID of the flash lender object the debt holder borrowed from
        flash_lender_id: ID,
        /// Total amount of funds the borrower must repay: amount borrowed + the fee
        repay_amount: u64
    }
    
    
    /// Request a loan of `amount` from `lender`. The returned `Receipt<T>` "hot potato" ensures
    /// that the borrower will call `repay(lender, ...)` later on in this tx.
    /// Aborts if `amount` is greater that the amount that `lender` has available for lending.
    public fun loan<T>(
        self: &mut FlashLender<T>, amount: u64, ctx: &mut TxContext
    ): (Coin<T>, Receipt<T>) {
    
    let to_lend = &mut self.to_lend;
        assert!(balance::value(to_lend) >= amount, ELoanTooLarge);
        let loan = coin::take(to_lend, amount, ctx);
        let repay_amount = amount + self.fee;
        let receipt = Receipt { flash_lender_id: object::id(self), repay_amount };
    
    
        (loan, receipt)
    }
    
    
    /// Repay the loan recorded by `receipt` to `lender` with `payment`.
    /// Aborts if the repayment amount is incorrect or `lender` is not the `FlashLender`
    /// that issued the original loan.
    public fun repay<T>(self: &mut FlashLender<T>, payment: Coin<T>, receipt: Receipt<T>) {
        let Receipt { flash_lender_id, repay_amount } = receipt;
        assert!(object::id(self) == flash_lender_id, ERepayToWrongLender);
        assert!(coin::value(&payment) == repay_amount, EInvalidRepaymentAmount);
    
    
        coin::put(&mut self.to_lend, payment)
    }

    Solution: Remove the store and drop ability from the struct Receipt.

    struct Receipt<phantom T> {
        /// ID of the flash lender object the debt holder borrowed from
        flash_lender_id: ID,
        /// Total amount of funds the borrower must repay: amount borrowed + the fee
        repay_amount: u64
    }
  • The metadata in Coin should be frozen

    In Sui, except for certain NFT types that aim to implement creative functionalities, the metadata of all coins, should be frozen. If the metadata is not frozen, it can lead to confusion for users if the administrator modifies that data.

    The following code creates a T coin by calling the Sui framework function coin::create_currency(). The second return value of this function is of type coin::CoinMetadata<T>, which stores the metadata of the coin. In this code, the metadata of the T coin is not frozen, allowing the person with the treasury cap to freely modify information such as the name, symbol, and other details.

     fun init(witness: T, ctx: &mut TxContext) {
        let (treasury_cap, metadata) = coin::create_currency<T>(witness,
            9,
            b"Token",
            b"Token",
            b"It is the native token for xxx",
            option::none(),
            ctx);
        transfer::public_transfer(treasury_cap, tx_context::sender(ctx))
        transfer::public_share_object(metadata);
    }

    Suggestion: The fix for this issue is quite simple. You just need to Freeze it instead of sharing.

    transfer::public_freeze_object(metadata);
  • Verify the proper usage of generics

    In terms of public functions in Move, generic types are the most common type parameter. Although the bytecode verifier performs certain security checks, We still need to be cautious about the pitfalls that generics can introduce. These pitfalls can potentially lead to significant losses if not handled properly.

    Here is a code snippet from a gameFi platform that represents the purchasing of packs. Its main functionality is to allow users with signed information to use a certain quantity of coins to buy packs. In the signature verification process, there is no check on the correlation between the incoming COIN type and the order. This means that any type of Coin can be used for the operation. Users have the option to purchase Packs using low-value tokens, which may result in significant losses for the project.

    public entry fun buy_pack<COIN>(game: &mut Game, paid: Coin<COIN>, pack_id: u8, timestamp: u64, signature: vector<u8>, ctx: &mut TxContext) {
       let token_amount = coin::value(&paid);
       let player = tx_context::sender(ctx);
       let message = PackMessage {
           prefix: BUY_PACK_PREFIX,
           token_amount: token_amount,
           pack_id: pack_id,
           game: object::id_address(game),
           owner: player,
           timestamp: timestamp,
       };
       let message_bytes = bcs::to_bytes(&message);
       let recovered_address = verifier::ecrecover_to_eth_address(signature, message_bytes);
       assert!(game.verifier == recovered_address, ESigFail);
     }

    Solution: One suggestion is including the token type in the signature verification.

    Here is a simple example of the fixed code:

     let message = PackMessage {
         prefix: BUY_PACK_PREFIX,
         token_amount: token_amount,
      coin_type: type_name::get<COIN>(), // include the coin type
         pack_id: pack_id,
         game: object::id_address(game),
         owner: player,
         timestamp: timestamp,
    };
  • Avoid using non-global variables as parameters in entry functions

    In Sui Move, for global object-type parameters in entry functions, the code does not use borrow_global_mut to obtain global resources. Instead, when making a Sui Move function call from CLI or SDK, the caller passes in the object ID, and Sui automatically retrieves the object from the global storage. This approach enhances development convenience, but it can potentially lead to certain functionalities becoming inaccessible or unavailable.

    For example, in the get_value function below, it accepts a Balance type parameter, this code compiles successfully. However, after deployment, this function cannot be called properly through the CLI or SDK, resulting in the inability to use the business function.

     struct Balance has store {
         value: u64
    }
    public entry fun get_value(balance : &Balance): u64 {
         balance.value
    }

    Solution: Modify Balance struct, add an id field of UID type, make it to be a Sui Move Object Struct, and add a new function to create Balance objects.

     struct Balance has store {
         id: UID,
         value: u64
     }
    public entry fun new(val : u64, ctx: &mut TxContext){
         transfer::transfer(Balance {
             id: object::new(ctx),
             value: val
       }, tx_context::sender(ctx));
     }
  • Proper usage of Share Object

    Shared object is an object that is shared using the sui::transfer::share_object or sui::transfer::public_share_object function and is accessible to everyone. By sharing objects, multiple accounts can read from and modify the object without duplicating it. This sharing mechanism is useful when implementing complex protocols and contracts.

    It is crucial to use Share Objects correctly; otherwise, it can lead to disastrous consequences.

    Let’s take a look at the code snippet below, the AdminCapstruct represents an administrative capability and contains a single field id of type UID. With the AdminCap, users can invoke the set_pool_fee to update the the fee of the pool. Improper sharing of AdminCap allows anyone with access to the AdminCap object to modify the pool fee, which is unintended and can have adverse effects.

     struct AdminCap has key {
         id: UID
     }
    
    
    fun init(ctx: &mut TxContext) {
         let admin_cap = AdminCap {
             id: object::new(ctx)
         };
         transfer::share_object(admin_cap);
     }
    
    
    public entry fun set_pool_fee(
         _: &AdminCap,
         pool: &Pool,
         new_fee: u64) {
         pool.fee = fee;
         event::emit(PoolFeeUpdate {
             new_fee: fee
         });
     }

    Solution: Transfer the AdminCap to the creator instead of sharing it.

    fun init(ctx: &mut TxContext) {
        let admin_cap = AdminCap {
            id: object::new(ctx)
        };
        transfer::public_transfer(admin_cap, tx_context::sender(ctx));
    }
  • For complex smart contracts, we may use multiple shared objects to store different sets of data, with users accessing the corresponding objects as needed. In some cases, there may be certain relationships or dependencies between these objects. It is crucial to validate these relationships when using the objects to avoid data inconsistency and potential vulnerabilities to attacks.

    Here is a partial code snippet of a Launchpad contract. The create_launchpad function is used to create a new Launchpad along with its corresponding whitelist. The invest function allows whitelisted users to purchase TR tokens using Coin T1. However, in the invest function, there is no validation to check if the provided whitelist is consistent with the Launchpad. This can result in users from other Launchpads’ whitelists participating in this Launchpad as well.

    public entry fun create_launchpad<TI, TR>(
        _: &mut AdminCap,
        launchpadID: String,
        target_amount: u64,
        tokens_to_sell: u64,
        receiver: address,
        ctx: &mut TxContext
        ) {
            // Create launchpad and corresponding whitelist
            let launchpad = new<TI, TR>(
    launchpadID,
                target_amount,
                tokens_to_sell,
                receiver,
                ctx
            );
            let whitelist = Whitelist {
                id: object::new(ctx),
                launchpad_id: launchpadID,
                allowed_addresses: vector::empty<address>()
            }
    
    
            // Make objects shared to be accessed for everyone
            transfer::public_share_object(launchpad);
            transfer::public_share_object(whitelist);
     }
    
    
    public entry fun invest<TI, TR>(
        launchpad: &mut Launchpad<TI, TR>,
        whitelist: &Whitelist,
        coin: &mut coin::Coin<TI>,
        amount: u64,
        ctx: &mut TxContext
     ) {
        // Verify if sender is present in the whitelist
        assert!(whitelist::contains(whitelist, tx_context::sender(ctx)), ENotInWhitelist);
    
    
        let coins_to_invest = coin::split(coin, amount, ctx);
        let invest_balance = coin::into_balance(coins_to_invest);
        balance::join(&mut launchpad.investment_vault, invest_balance);
    
    
        let invest_cert = InvestCertificate{
            id: object::new(ctx),
            launchpadID: get_id(launchpad),
            deposit: amount
        };
        launchpad.invested_amount = launchpad.invested_amount + amount;
        launchpad.investors_count = launchpad.investors_count + 1;
        transfer::public_transfer(invest_cert, tx_context::sender(ctx));
    
    
        event::emit(InvestedInLaunchpad{
    launchpad_id: get_id(launchpad),
            investor: tx_context::sender(ctx),
            amount: amount
        });
     }

    Solution: Add consistency validation to ensure the provided whitelist matches the Launchpad.

    public entry fun invest<TI, TR>(
         launchpad: &mut Launchpad<TI, TR>,
         whitelist: &Whitelist,
         coin: &mut coin::Coin<TI>,
         amount: u64,
         ctx: &mut TxContext
     ) {
         // Verify if sender is present in the whitelist
         assert!(whitelist::contains(whitelist, tx_context::sender(ctx)), ENotInWhitelist);
    
          //Verify if the whitelist matches the launchpad.
          assert!(whitelist.launchpad_id == launchpad.id, EInconsistent)
       …
      }

Conclusion

The introduction of Objects in Sui Move has resulted in significant changes to the original Move programming method. However, by understanding the Sui Move security model and mastering the operation essentials of Sui Objects, developers can better utilize these features while avoiding security issues.

About MoveBit

MoveBit is a blockchain security company focused on the Move Ecosystem by pioneering the use of cutting-edge Formal Verification. The team consists of security professionals from academia and enterprise with 10 years of security experience. they were one of the earliest contributors to the Move ecosystem, working with Move developers to set the standard for secure Move applications and make the Move ecosystem the most secure Web3 destination.

Twitter: https://twitter.com/MoveBit_

Medium: https://movebit.medium.com/

Github: https://github.com/movebit

Discord: https://discord.com/invite/7wM8VU9Gyj

OLDER > < NEWER