블록체인 기술이 전 세계적으로 주목받으면서 스마트 컨트랙트(Smart Contract)의 사용이 급격히 증가하고 있습니다. 하지만 그만큼 보안 취약점도 함께 늘어나고 있는데, 그 중에서도 Reentrancy Attack(재진입 공격)은 매우 치명적이고 널리 알려진 공격 기법 중 하나입니다. 이 공격 방법의 원리와 방어 방법을 자세히 살펴보겠습니다.
Reentrancy Attack이란?
Reentrancy Attack은 스마트 컨트랙트의 취약점을 이용하여, 외부 호출이 끝나기 전에 같은 함수를 반복 호출함으로써 자금을 탈취하는 공격입니다. 특히 이더리움 같은 플랫폼에서 많이 발생합니다. 공격자는 스마트 컨트랙트가 특정 상태 업데이트를 완료하기 전에 재진입하여 여러 번 호출함으로써 계약의 상태를 왜곡시키고 자금을 반복적으로 인출할 수 있습니다.
공격 원리
- 스마트 컨트랙트의 취약점 탐색: 공격자는 특정 스마트 컨트랙트 코드에서 외부 호출을 포함하는 함수를 찾아냅니다.
- 악의적인 컨트랙트 생성: 공격자는 자신이 통제할 수 있는 악의적인 스마트 컨트랙트를 생성합니다.
- 재진입 호출: 악의적인 컨트랙트를 이용하여 원래 스마트 컨트랙트의 함수를 재진입합니다. 이 과정에서 원래 컨트랙트가 상태를 업데이트하기 전에 동일한 함수가 반복적으로 호출됩니다.
- 자금 탈취: 재진입이 성공할 경우, 공격자는 스마트 컨트랙트에서 자금을 반복적으로 인출할 수 있습니다.
사례 분석
2016년에 발생한 The DAO 해킹 사건은 Reentrancy Attack의 대표적인 사례입니다. 공격자는 The DAO의 스마트 컨트랙트에서 재진입 취약점을 발견하고 이를 통해 약 360만 이더(당시 약 5000만 달러)를 탈취했습니다. 이 사건은 이더리움 커뮤니티에 큰 충격을 주었고, 결국 이더리움의 하드포크를 초래했습니다.
방어 방법
Reentrancy Attack을 방어하기 위해 다음과 같은 전략을 사용할 수 있습니다.
1. Checks-Effects-Interactions 패턴: 외부 호출 전 상태를 업데이트하고, 그 후에 외부 호출을 수행합니다. 이렇게 하면 재진입 시 상태가 이미 업데이트되어 공격이 무효화됩니다.
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount);
balances[msg.sender] -= _amount;
(bool success, ) = msg.sender.call{value: _amount}("");
require(success);
}
Checks-Effects-Interactions(CEI) 패턴은 스마트 컨트랙트에서 외부 호출로 인한 보안 문제를 방지하기 위해 설계된 중요한 기법입니다. 이 패턴은 상태 변화를 먼저 수행하고 그 후에 외부 호출을 하는 구조로, 재진입 공격을 방지하는 데 유용합니다. 아래는 CEI 패턴을 구체적으로 적용한 예제를 더 살펴보겠습니다.
예제 1: 단순 인출 함수
이 예제에서는 사용자가 자신의 잔액을 인출할 수 있는 스마트 계약을 보여줍니다. CEI 패턴을 사용하여 재진입 공격을 방지합니다.
pragma solidity ^0.8.0;
contract SafeWithdraw {
mapping(address => uint) public balances;
// 예치 기능
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// 인출 기능
function withdraw(uint _amount) public {
// Checks: 인출 금액이 잔액보다 크지 않은지 확인
require(balances[msg.sender] >= _amount, "Insufficient balance");
// Effects: 상태를 먼저 업데이트
balances[msg.sender] -= _amount;
// Interactions: 외부 호출 수행
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Transfer failed");
}
}
이 예제에서 withdraw 함수는 먼저 사용자의 잔액을 확인하고(Checks), 잔액에서 인출할 금액을 차감한 후(Effects), 마지막으로 인출을 수행합니다(Interactions). 이러한 순서를 통해 외부 호출이 이루어지기 전에 상태가 안전하게 업데이트됩니다.
예제 2: 경매 계약
이 예제는 경매 계약에서 최고 입찰자가 인출할 수 있는 기능을 구현한 것입니다. CEI 패턴을 사용하여 입찰자가 인출할 때 재진입 공격을 방지합니다.
pragma solidity ^0.8.0;
contract Auction {
address public highestBidder;
uint public highestBid;
mapping(address => uint) public pendingReturns;
// 입찰 기능
function bid() public payable {
require(msg.value > highestBid, "There already is a higher bid");
if (highestBidder != address(0)) {
// 이전 최고 입찰자에게 환불
pendingReturns[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
}
// 환불 기능
function withdraw() public {
uint amount = pendingReturns[msg.sender];
require(amount > 0, "No funds to withdraw");
// Checks: 환불 금액 확인
pendingReturns[msg.sender] = 0; // Effects: 상태를 먼저 업데이트
// Interactions: 외부 호출 수행
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
이 경매 컨트랙트에서 bid 함수는 새로운 입찰자가 기존 최고 입찰자보다 높은 입찰을 할 때, 기존 최고 입찰자의 입찰금을 pendingReturns에 추가합니다. withdraw 함수는 pendingReturns에서 환불할 금액을 먼저 확인한 후, 상태를 업데이트하고 외부 호출을 수행합니다.
위 예제들은 CEI 패턴을 활용하여 재진입 공격을 방지하는 방법을 구체적으로 보여줍니다. 스마트 컨트랙트 개발자는 항상 CEI 패턴을 적용하여 안전한 코드를 작성해야 합니다.
2. Reentrancy Guard 사용: 재진입 방지를 위해 상태 변수를 사용하여 함수가 재진입되었는지를 체크합니다.
bool private locked;
modifier noReentrancy() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}
function withdraw(uint _amount) public noReentrancy {
require(balances[msg.sender] >= _amount);
balances[msg.sender] -= _amount;
(bool success, ) = msg.sender.call{value: _amount}("");
require(success);
}
3. 외부 호출 최소화: 외부 계약 호출을 최소화하여 재진입 가능성을 줄입니다. 필요한 경우, 가능한 한 간단한 형태로 유지합니다.
결론
Reentrancy Attack은 스마트 컨트랙트의 보안에 있어서 매우 중요한 문제입니다. 이 공격을 방어하기 위해서는 코드 작성 시 각별한 주의가 필요하며, 위에서 언급한 방어 전략을 적극적으로 적용해야 합니다. 스마트 컨트랙트 개발자는 항상 코드의 취약점을 사전에 파악하고, 최신 보안 기법을 적용하여 안전한 블록체인 환경을 구축해야 합니다.