1. Concept
Reentrancy attack의 원리에 대해 알아봅시다. Reentrancy attack이란, 간단히 말해서 A 컨트랙트에 이더를 예치하고 출금할 수 있을 때, 악의적인 B 컨트랙트에서 A 컨트랙트의 취약점을 공략한 함수를 실행하여 A 컨트랙트에 재집입하여 이더를 계속 출금하는 공격입니다.

A contract는 일종의 은행 역할을 하는 컨트랙트입니다. A contract에는 자산 예치, 출금 기능이 존재합니다. 출금 함수는 돈을 출금해주기 전에 예치된 자산이 충분한지 확인하는 검사 과정을 거칩니다. 그런데 이는 Reentrancy attack에 취약합니다.
상황을 가정해봅시다. A contract의 자산을 모두 뺏어가려는 B contract가 있다고 합시다. 먼저 B는 A에 1 ether를 예치해 놓습니다.

그 후에 B에서 A의 출금 함수를 호출해서 예치해 놓은 1 ether를 빼갑니다.

그런데 바로 다음 로직에서 문제가 발생합니다. A에서 B의 자산을 업데이트하기 전에 B가 다시 출금 함수를 호출합니다. 출금 함수가 호출된 시점에 B의 자산은 아직 업데이트 되지 않은, 즉 1 ether이므로 출금 함수의 검사 과정을 통과하고 출금이 가능합니다. 이런 방식으로 B는 계속 A의 출금 함수를 호출하고, 결국 A는 가지고 있는 자산을 모두 뺏길 때까지 B에게 출금해줍니다.

함수의 실행 과정을 보면 위와 같습니다. (1) B의 attack 함수에서 A의 withdraw 함수를 호출합니다. B의 withdraw 함수에서는 balance를 먼저 check하고, (2) B에게 ether를 전송합니다. B는 ether를 받음으로써 fallback 함수가 실행되고, (3) fallback 함수 내부에서 A의 withdraw 함수를 다시 호출합니다. (2), (3) 과정을 계속해서 반복하는 것이 Reentrancy attack입니다.
하나하나 자세히 보도록 하겠습니다.

위의 그림처럼 출금 함수를 가진 A(공격 받는)와 B(공격 하는)가 있다고 하자. B에는 attack 함수와 fallback 함수가 있다.
악의적인 사용자 Eve는 B의 attack 함수를 실행시키고 이는 A의 withdraw 함수를 실행시킨다. withdraw 함수는 다음과 같은 동작을 수행한다.
1. A에 저장된 B의 balance를 확인한다.
2. B에게 이더를 출금해준다. == B에게 이더를 송금한다.
3. A에 저장된 B의 balance를 초기화해준다.
1번 과정은 B가 요청한 금액만큼 출금해줄 수 있는지 확인해주는 과정이다. 만약 내가 은행에 1000원을 넣었는데 10000원을 출금한다고 하면 안되지 않겠는가?

2번 과정에서 A가 B에게 이더를 송금하기에 B의 fallback 함수가 실행된다. fallback 함수에 대한 설명은 다음 블로그를 참고하자.
그런데 B의 fallback 함수를 보면 A의 withdraw 함수를 다시 실행시키고 있다.

B의 balance를 업데이트 해주는 3번 과정이 아직 실행되지 않았기에 withdraw를 다시 실행했을 때 B의 balance는 출금하지 이전의 balance와 동일하다. (실제로는 A가 B에게 송금을 했지만 아직 업데이트가 되지 않은 것이다.) 따라서 1번의 check balance 과정을 또 통과할 수 있다.
이렇게 계속 반복하다 보면 A의 balance가 0이 될 때까지 B에게 모든 이더를 송금하게 된다. 따라서 B는 자신의 A에 예치한 금액과 상관없이 A 자체가 가지고 있는 모든 balance를 뺏어올 수 있는 것이다.
2. Code
contract EtherStore {
mapping (address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public {
//1. check balance
require(balances[msg.sender] >= _amount);
//2. send ether
(bool sent, ) = msg.sender.call{value:_amount}("");
require(sent, "Failed to send Ether");
//3. update balance
balances[msg.sender] -= _amount;
}
function getBalance() public view returns (uint){
return address(this).balance;
}
}
vulnerable contract (A)
contract Attack {
EtherStore public etherStore;
constructor (address _etherStoreAddress) {
etherStore= EtherStore(_etherStoreAddress);
}
// fallback()
receive() external payable {
if(address(etherStore).balance >= 1 ether){
etherStore.withdraw(1 ether);
}
}
function attack() external payable {
require(msg.value >= 1 ether);
etherStore.deposit{value: 1 ether}();
etherStore.withdraw(1 ether);
}
}
attack contract (B)
3. Prevention
- withdraw 함수에서 2, 3번 과정의 순서를 바꾼다. 즉, balance를 먼저 update해주고 send한다.
- modifier를 사용해 reentrance를 막는다.
bool internal locked = false;
modifier noReentrant() {
require(!locked, "No-entrancy");
locked=true;
_;
locked=false;
}
function withdraw(uint _amount) public noReentrant{
...
}
위의 modifier를 사용하면 withdraw 함수가 실행됐을 때 locked가 true로 바뀌고 모든 과정을 끝낸 후에 false로 바뀌기에 withdraw 함수가 한번 실행되면 완전히 끝나기 전에는 다시 함수를 실행시킬 수 없도록 막는다.
4. Real World Example

DAO는 이더리움 체인 상에 구축된 최초의 분산형 자율 조직 중 하나였습니다. DAO의 주요 목적은 투자자로부터 이더를 모으고, 이더를 사용하여 다양한 프로젝트나 기업에 투자하는 것이었습니다. DAO는 성공적인 크라우드 펀딩 프로젝트 중 하나를 통해 1억 5천만 달러의 자금을 모금했습니다. 당시에 이정도의 이더는 상당히 큰 금액을 모금한 것이었습니다.
그러나, 토큰 판매 중에도 커뮤니티에서 지적받고 있던 코드 상의 취약점으로 인해 무려 360만 이더를 탈취 당해 버렸습니다. 해당 취약점으로 발생한 공격은 오늘날 Reentrancy attack, 즉 재진입 공격이라고 불립니다. 자금을 되찾기 위해 이더리움 하드 포크를 촉발했고 그 과정에서 이더리움 클래식이 탄생했습니다. 기존의 이더리움은 수정되지 않은 버전인 이더리움 클래식이 되었고, 현재의 이더리움은 수정된 버전에 해당합니다.

lendfme와 uniswap 역시 재진입 공격으로 피해를 입었습니다. lendfme protocol은 이더리움 플랫폼에서 대출 운영을 지원하기 위해 dForce Foundation에서 개발한 DeFi 프로토콜입니다.
lendfme 프로토콜의 컨트랙트 코드에 취약점이 존재했는데, 이는 ERC-777로 구현된 imBTC 토큰의 기능과 합쳐져 공격을 받을 수 있게 되었습니다. (imBTC – 이더리움 플랫폼에서 실행되며 비트코인 암호화폐와 1:1 비율로 평가되는 토큰, ERC-777 – Contract을 지원하기 위한 Ethereum 블록체인의 기본 기술 중 하나)
해커는 보안 감사를 수행하는 회사인 OpenZeppelin이 2019년 7월 GitHub에 게시한 익스플로잇 방법으로 공격 수행했는데, 그 방법이 재진입 공격이었습니다. 재진입 공격으로 lendfme protocol에 imBTC 공급량 계속 증가시켰고, 담보 금액 기반으로 다른 유동성 풀에서 자산 탈취가 가능했습니다.
실제로 imBTC의 회사인 Tokenlon에서 “ERC-777 토큰 표준에는 우리가 아는 한 보안 취약점이 없다.”라고 했다가, 사후보고서에는 “그러나 ERC777 토큰과 Uniswap/Lendf.Me 계약을 함께 사용하면 재진입 공격이 가능해진다.”라고 작성했다고 합니다.
토큰 별 손실 금액은 다음과 같습니다.

5. Reference
- https://medium.com/@losslessdefi/the-dao-hack-story-f519c1dc78f7 – The DAO hack
- https://peckshield.medium.com/uniswap-lendf-me-hacks-root-cause-and-loss-analysis-50f3263dcc09 – lendf.me & uniswap hack
- https://www.youtube.com/watch?v=4Mm3BCyHtDY&list=PLO5VPQH6OWdWsCgXJT9UuzgbC8SPvTRi5&index=2&ab_channel=SmartContractProgrammer – reentrancy video