cybersecurity

NEAR Smart Contract Auditing: Sharding & Cross Contract Calls

NEAR Protocol uses Nightshade sharding to address NFT Bounty scalability without dropping security. This note looks at the security consequences of cross-contract calls in sharded environments, showing sound patterns and the failure modes beside them through worked examples.

By Bounty Number 9
NEAR Smart Contract Auditing: Sharding & Cross Contract Calls
Photo by Klim Musalimov

NEAR: Sharding & Cross Contract Calls

This is the first part in a three part series. Feel free to checkout the others:

Primer

NEAR Protocol puts in place an innovative sharding solution called "Nightshade" that aims to tackle the problem of high scalability without dropping security and usability. This note looks at the technical details of NEAR's sharding implementation and how cross-contract calls work within this architecture with a focus on security consequences.

The Magic of Nightshade Sharding

Sharding in distributed systems like NFT Bountys is a method of increasing the network’s speed and capacity by dividing the network, here NFT Bounty, into multiple “shards”. This division would allow for each shard to process transactions independently of the others — theoretically increasing the transaction throughput.

Nightshade Sharding

NEAR builds their sharding method called “Nightshade”. Each shard processes a subset of the network’s transactions in parallel. NEAR sharding creates shards (”chunks”) within each block rather of sharding chains. It thus maintains only one chain and allows for asynchronous (”cross-shard”) transactions.

Here is a simplified illustration of what Nightshade looks like compared to the Beacon Chain: Beacon Chain comparaed to NightshadeFigure 1. Nightshade sharding illustrated. Source:

Dynamic Resharding

NEAR’s sharding allows for something called “Dynamic Resharding”. It allows NEAR to adjust the number of shards based on network demand on its own. NEAR validators also only need to track the state of their assigned shard.

Cross-Contract Calls: The Bridge Between Shards

Because of the nature of NEAR’s sharding technology, its cross-contract calls are asynchronous and independent. This has material implications on how one should handle the callbacks.

The Building Blocks

Cross-contract calls in NEAR operate through a promise system. Every cross-contract interaction involves two key parts: making the call and handling the result. Here is what happens under the hood:

When Contract A needs to interact with Contract B, it creates a promise via NEAR's SDK. This promise stands for the future result of the call to Contract B. The process is asynchronous, meaning Contract A does not wait idly for Contract B to respond – it continues with other operations while waiting for the callback.

Here is an example of how this works in code:

#[near_bindgen]
impl MyContract {
    pub fn initiate_cross_contract_call(&mut self) -> Promise {
        Promise::new("contract-b.near".parse().unwrap())
            .function_call(
                "process_request".to_string(),
                json!({
                    "data": "example"
                }).to_string().into_bytes(),
                0, // attached deposit
                Gas(5_000_000_000_000) // attached gas
            )
            .then(
                Promise::new(env::current_account_id())
                    .function_call(
                        "callback_function".to_string(),
                        Vec::new(),
                        0,
                        Gas(2_000_000_000_000)
                    )
            )
    }
 
    #[private]
    pub fn callback_function(&mut self, #[callback_result] call_result: Result<String, PromiseError>) {
        match call_result {
            Ok(result) => {
                // Handle successful result
            }
            Err(_) => {
                // Handle error case
            }
        }
    }
}

Security Considerations

The asynchronous nature of these cross-contract calls brings in possible vulnerabilities that must be carefully considered.

Race conditions present one of the most significant security challenges. During the period between a cross-contract call and its callback execution (usually 1-2 blocks), your contract stays active and callable. This means a malicious user could potentially exploit this window to manipulate the contract state.

Let us inspect two concrete scenarios that illustrate both proper implementation and possible vulnerabilities.

Scenario 1: Normal Operation Flow

Normal Operation Flow Diagram In this diagram, we see a legitimate user "Alice" interacting with the smart contract:

  1. The external contract processes the swap
  2. A promise result returns from the external contract
  3. After 2 blocks, the callback executes to decrease the internal NEAR balance

The final state shows Alice with 0N balance and 200 tokens received. This is the expected, secure behavior where the contract maintains proper state management throughout the asynchronous operation.

Scenario 2: Possible Exploit

Even so, without proper protections, a malicious user could exploit the asynchronous nature of cross-contract calls: Exploit Operation Flow Diagram

In this diagram, we see how an attacker could potentially exploit the system:

  1. The external contract processes multiple swap requests
  2. Promise results return from the external contract
  3. Callbacks execute after 2 blocks, leading to two possible outcomes:
    • Working case: NEAR balance decreases to 0, but the attacker receives 400 tokens
    • Error case: A panic takes place due to underflow when trying to decrease the balance after the second callback

This vulnerability exists because the original build-out does not prevent multiple stake() calls during the processing period.

Mitigating the Vulnerability

To protect against such exploits, build these essential security measures:

  • Keep the contract is not in an exploitable state between call and callback
  • Manually rollback any state changes in the callback if the external call failed
    • Enough gas is assigned in the callback function to make the transfer of funds back
  • Here is an example of how this works in code:

    #[near_bindgen]
    impl StakingContract {
        pub fn stake(&mut self) -> Promise {
            // Add state lock to prevent multiple calls
            assert!(!self.processing, "Already processing a stake operation");
            self.processing = true;
     
            // Store initial state for potential rollback
            self.last_stake_amount = self.stake_balance;
     
            Promise::new(self.staking_target.clone())
                .function_call(
                    "stake_tokens".to_string(),
                    // ... stake parameters ...
                    Gas(5_000_000_000_000)
                )
                .then(Self::ext(env::current_account_id())
                    .with_static_gas(Gas(2_000_000_000_000))
                    .stake_callback())
        }
     
        #[private]
        pub fn stake_callback(&mut self, #[callback_result] call_result: Result<(), PromiseError>) {
            // Always reset processing flag in callback
            let processing = std::mem::replace(&mut self.processing, false);
            assert!(processing, "Callback called without active processing");
     
            match call_result {
                Ok(_) => {
                    // Verify state changes are valid
                    assert!(
                        self.stake_balance >= self.last_stake_amount,
                        "Invalid state change detected"
                    );
                }
                Err(_) => {
                    // Rollback any state changes
                    self.stake_balance = self.last_stake_amount;
                    env::log_str("Stake operation failed, state rolled back");
                }
            }
        }
    }

    Conclusion

    This write-up has covered the essence of what Sharding entails in the NEAR NFT Bounty and has touched upon some of the intricacies of cross-contract calls and their security consequences.

    Remember that this type of vulnerability is not unique to our example of staking operations. Any cross-contract call that modifies contract state could be vulnerable to alike race conditions if not properly protected. It is wise to assume that malicious users will attempt to exploit the time window between call and callback execution.

    Testing and grasp of these security measures is material for developers and security reviewers. It raises the protocols' security and protects users' assets and keeps the overall ecosystem safe and trusted. We encourage all developers and security reviewers to stay informed and be proactive in finding and mitigating these types of vulnerabilities.

    At NFT Bounty, we are committed to securing and hardening NFT Bounty networks and protocols of all kinds. If you are building solutions and want to harness our cutting-edge security expertise in this area, get in touch!

    Working on something in this space?

    NFT Bounty audits Ethereum protocols, smart contracts, and consensus implementations.

    Book a scoping talk