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
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) havecopy
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 havecopy
ability.drop
: The value of this type can be automatically destroyed at the end of the scope. Thedrop
ability is usually used for resource types. Types withdrop
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 havedrop
Ability.key
: In the original Move, the values in the global storage must havekey
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 haskey
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 issui::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’sid: 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 havekey
ability. For the public ones, T can be defined in different modules and must have thekey & 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
anddrop
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 therepay
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 ofstore
anddrop
. 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
anddrop
ability from the structReceipt
.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 typecoin::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 thetreasury 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 aBalance
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 anid
field ofUID
type, make it to be a Sui Move Object Struct, and add a new function to createBalance
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
orsui::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
AdminCap
struct represents an administrative capability and contains a single fieldid
of typeUID
. With theAdminCap
, users can invoke theset_pool_fee
to update the the fee of the pool. Improper sharing ofAdminCap
allows anyone with access to theAdminCap
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)); }
Validate the consistency of multiple related parameters
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. Theinvest
function allows whitelisted users to purchase TR tokens using Coin T1. However, in theinvest
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