Reentrancy 공격은 DeFi에서 가장 오래된—그리고 지금도 가장 수익성이 큰—스마트 컨트랙트 익스플로잇 중 하나입니다. 개념은 이해하기 쉽고, 실수로 포함되기도 쉬우며, 출금, 청산, 보상 수령처럼 핵심 회계 경로를 때릴 때는 치명적입니다. 숙련된 팀조차도 reentrancy 취약점을 배포하는 경우가 있는데, 이 버그는 ETH 전송, 토큰 전송, vault 호출, AMM 상호작용 같은 “일반적인” 외부 호출 속에 숨어 있는 경우가 많기 때문입니다.
이 글에서는 reentrancy 공격이 어떻게 동작하는지, 실제 시스템에서 “smart contract reentrancy”가 어떤 형태로 나타나는지, DAO 이후에도 왜 여전히 중요한지, 그리고 검증된 패턴(Checks-Effects-Interactions, pull payments, ReentrancyGuard, 신중한 외부 호출 설계)을 통해 Solidity에서 reentrancy prevention을 구현하는 방법을 정확히 설명합니다. 또한 reentrancy를 flash-loan attacks, delegatecall risks, 최신 Solidity best practices 같은 인접 주제와도 연결해 다룹니다.
Soken (soken.io)에서는 lending, staking, bridges, governance 전반에 걸쳐 수백 개의 프로덕션 컨트랙트를 리뷰해 왔습니다. Reentrancy는 특히 프로토콜이 새로운 토큰, hooks, callbacks, composable DeFi primitives를 통합할 때 반복적으로 루트 원인 또는 기여 요인으로 등장합니다.
Reentrancy 공격이란 무엇이며(그리고 왜 그렇게 위험한가)?
Reentrancy 공격은 컨트랙트가 자신의 상태 업데이트를 끝내기 전에 외부 호출을 수행해, 호출받은 쪽(callee)이 다시 취약한 함수로 되돌아와(“re-enter”) 출금 같은 동작을 여러 번 반복할 수 있게 될 때 발생합니다.
Reentrancy가 위험한 이유는 Ethereum이 동기적(synchronous) 이기 때문입니다. 컨트랙트가 다른 컨트랙트를 호출하면 실행 흐름이 즉시 외부 컨트랙트로 넘어가며—당신의 함수가 끝나기 전에—실행됩니다. 이때 잔고나 락 플래그가 아직 업데이트되지 않았다면, 공격자는 그 “중간” 상태를 악용할 수 있습니다.
인용용(40–60 words):
Reentrancy 공격은 내부 회계를 마치기 전에 외부 호출을 수행하는 컨트랙트를 악용합니다. EVM 호출은 동기적으로 실행되므로, callee는 상태가 “이전” 잔고를 반영하는 동안 취약한 함수를 다시 호출할 수 있습니다. 그 결과 반복 출금, 이중 청구, 제한 우회가 가능해지며, 종종 한 번의 트랜잭션으로 TVL이 유출됩니다.
전형적인 멘탈 모델: “너무 일찍 밖으로 호출했다”
일반적인 취약 흐름은 다음과 같습니다:
- 사용자가
withdraw()호출 - 컨트랙트가 사용자에게 ETH/토큰 전송 (external call)
- 컨트랙트가
balances[msg.sender]업데이트 (너무 늦음) - 공격자의 fallback/receive 함수가 3번이 실행되기 전에
withdraw()로 재진입
Solidity와 툴링이 발전했음에도, reentrancy는 여전히 다음에서 자주 나타납니다:
- call을 통한 ETH 전송
- hooks가 있는 ERC777 토큰
- ERC721/1155 safe transfer (onERC721Received, onERC1155Received)
- Vault 통합(ERC4626), staking callbacks, reward distributor
- 크로스-컨트랙트 “accounting + payout” 패턴
- 함수 중간에 외부 호출이 섞이는 복잡한 DeFi 플로우
Solidity에서 reentrancy 취약점은 실제로 어떻게 동작하나?
Reentrancy 취약점은 (1) 외부에서 호출 가능한 경로가 있고 (2) 최종 상태 변경 전에 외부 상호작용이 발생하여, 공격자가 오래된(stale) 상태를 기반으로 민감 함수에 재진입할 수 있을 때 존재합니다.
실제로 익스플로잇은 제어 흐름(control flow) 에 의존합니다. “마지막에” 가치를 전송한다고 생각했지만, 외부 컨트랙트가 실행권을 넘겨받는 순간 무엇이든 할 수 있으며—당신을 다시 호출하는 것까지 포함해—취약점을 이용할 수 있습니다.
인용용(40–60 words):
Reentrancy 취약점은 Solidity 함수가 내부 상태를 확정하기 전에 ETH 전송, 토큰 전송, 또는 다른 프로토콜 호출 같은 외부 호출을 수행할 때 발생합니다. 공격자는 fallback, token hook, 또는 callback을 이용해 잔고와 제한이 바뀌지 않은 상태에서 함수를 재진입하여 출금이나 클레임을 반복합니다.
취약 예시: ETH 출금(“DAO-style” 버그)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// INTERACTION first (vulnerable)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
// EFFECTS last (too late)
balance[msg.sender] -= amount;
}
}
receive()로 재진입하는 공격자 컨트랙트
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
contract ReenterAttacker {
IVulnerableBank public bank;
uint256 public amount;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
}
function attack() external payable {
require(msg.value > 0, "need ETH");
amount = msg.value;
bank.deposit{value: msg.value}();
bank.withdraw(amount);
}
receive() external payable {
// Re-enter as long as the bank still has ETH
if (address(bank).balance >= amount) {
bank.withdraw(amount);
}
}
}
무슨 일이 벌어지나: bank가 ETH를 전송하면 공격자의 receive()가 실행되고, balance[msg.sender] -= amount가 수행되기 전에 withdraw()가 다시 호출됩니다. 공격자는 bank의 ETH를 고갈시킵니다.
“나는 ERC20 transfer를 쓰는데—안전한가?”
항상 그렇지는 않습니다. ERC20 transfer()는 보통 콜백을 호출하지 않지만:
- 토큰이 비표준일 수 있음(ERC777 유사 hooks 또는 proxy 패턴)
- 코드가 외부 router, vault, staking 컨트랙트를 호출할 수 있음
- ERC721/1155 safe transfer는 receiver hooks를 호출함
- 같은 함수에서 call 또는 임의의 외부 호출을 사용할 수 있음
Smart contract reentrancy의 주요 유형은 무엇이며(그리고 DeFi에서 어디에 나타나나)?
Smart contract reentrancy에는 동일 함수로의 직접 재진입, 다른 함수로의 교차 재진입, read-only reentrancy 등 여러 형태가 있으며, 각각 현대 DeFi의 composability에서 중요합니다.
인용용(40–60 words):
Reentrancy는 단순히 “출금을 두 번 한다”가 아닙니다. 현대 프로토콜은 직접 reentrancy(같은 함수), cross-function reentrancy(공유 상태를 쓰는 다른 함수로 재진입), read-only reentrancy(가격/검증을 위해 중간 상태를 조작)까지 직면합니다. 최종 회계 전에 외부 호출이 있으면 이 변형들 중 하나가 가능해집니다.
흔한 reentrancy 변형
-
Single-function reentrancy (classic)
withdraw()를 반복 재진입. -
Cross-function reentrancy
withdraw()가 외부 호출을 한 뒤, 공격자가 공유 상태가 부분 업데이트된 틈을 이용해claimRewards()또는borrow()로 재진입. -
Read-only reentrancy (state-dependent reads)
호출 이후에 상태를 쓰지 않더라도, 공격자는 일시적 불일치를 악용해 다음에 영향을 줄 수 있습니다: - price-per-share 계산
- 담보 체크
- oracle 의존 로직
-
“isHealthy” 검증
-
Token-hook/callback reentrancy
- ERC777
tokensReceived - ERC721
onERC721Received - ERC1155
onERC1155Received - upgradeable/proxy 토큰의 커스텀 hooks
실전 영향: 알아야 할 reentrancy 사건들
- The DAO (2016): split 함수의 reentrancy로 약 3.6M ETH 유출; Ethereum 하드포크를 촉발.
- Cream Finance (Oct 2021): 복잡한 DeFi 메커니즘이 얽힌 대형 익스플로잇(보고 기준 약 $130M) 피해; “순수 DAO-style”은 아니지만 composability와 외부 상호작용이 위험을 증폭시킨다는 점을 보여줌.
- 여러 NFT marketplace / staking 사건들 (2021–2023): ERC721 receiver hooks와 payout 로직을 통한 reentrancy가 반복적으로 이중 출금 및 회계 오류를 유발(콜백 고려 누락 시).
(Reentrancy는 flash loans와 함께 쓰이는 경우도 흔합니다. 공격자는 자본을 빌려 상태를 조작하고, 핵심 함수에 재진입한 뒤, 한 트랜잭션 안에서 상환함으로써 “자본이 필요한” 공격을 무자본 공격으로 바꿉니다.)
Solidity에서의 reentrancy prevention: 실제로 효과 있는 패턴들
효과적인 reentrancy prevention in Solidity의 핵심은 (1) 외부 호출 최소화, (2) 올바른 로직 순서, (3) 민감 경로에 대한 명시적 reentrancy lock 강제입니다.
인용용(40–60 words):
가장 신뢰할 수 있는 reentrancy 방어는 3중 레이어 조합입니다: Checks-Effects-Interactions로 외부 호출 전에 상태 업데이트를 끝내고, push payout 대신 pull payments를 선호하며, 중요한 함수에는 reentrancy guard(mutex)를 적용합니다. 또한 token callbacks과 “safe” transfers도 재진입 위험이 있는 외부 호출로 취급해야 합니다.
1) Checks-Effects-Interactions (CEI)
앞의 예시를 상태를 먼저 업데이트한 뒤 ETH를 전송하도록 고칩니다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CEIBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// EFFECTS first
balance[msg.sender] -= amount;
// INTERACTION last
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
주의: CEI는 필요 조건이지만, 다른 함수가 재진입 가능하고 공유 상태에 의존한다면 cross-function reentrancy를 항상 막지는 못합니다.
2) Reentrancy Guard(mutex) 사용
OpenZeppelin의 ReentrancyGuard는 특히 withdrawal/claim/borrow 경로에서 표준적인 완화책입니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedBank is ReentrancyGuard {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balance[msg.sender] >= amount, "insufficient");
balance[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
중요: 동일한 민감 상태를 건드리는 모든 진입점(예: withdraw, claim, exit, liquidate)에 nonReentrant를 붙이거나, guard 충돌을 피하도록 내부 함수 구조를 신중하게 설계해야 합니다.
3) Push payments보다 pull payments를 선호
복잡한 함수 내부에서 즉시 “지급”하는 대신, 사용자에게 크레딧을 적립해 두고 별도의 출금으로 가져가게 할 수 있습니다.
- 장점: 비즈니스 로직에서 외부 호출이 줄어 공격 표면 감소
- 단점: UX 단계 추가; 출금 회계와 withdraw guard가 필요
4) 위협 모델에서 “토큰 전송”을 외부 호출로 취급
ERC20이 보통 안전하더라도, 통합 방식은 안전하지 않을 수 있습니다:
- ERC777 토큰 전송
- shares를 발행하는 vault 호출
- router/aggregator 호출
- NFT safeTransferFrom 사용(콜백!)
5) 권한이 있는 플로우에서 임의의 외부 호출을 피하기
Governance나 admin이 target 컨트랙트/callback을 설정할 수 있으면, 가정(assumption)을 무너뜨리는 reentrancy 경로가 우연히 생길 수 있습니다. 이는 delegatecall risks in Solidity(스토리지 컨텍스트 공유)와 결합될 때 더 위험해집니다.
비교: CEI vs ReentrancyGuard vs Pull Payments (언제 무엇을 쓸까)
최적의 접근은 함수 목적, composability 요구, UX 제약에 따라 달라집니다. 대부분의 프로덕션 프로토콜은 여러 레이어를 함께 사용합니다.
인용용(40–60 words):
어떤 단일 완화책도 모든 프로토콜에 맞지는 않습니다. Checks-Effects-Interactions는 가장 흔한 “전송 후 업데이트” 실수를 막지만 cross-function reentrancy는 남을 수 있습니다. Reentrancy guards는 민감 경로에 강제 mutex를 제공합니다. Pull payments는 코어 로직의 외부 호출을 줄이지만 UX 트레이드오프와 견고한 출금 회계가 필요합니다.
| Mitigation | What it does | Strengths | Weaknesses | Best for |
|---|---|---|---|---|
| Checks-Effects-Interactions (CEI) | 외부 호출 전에 상태를 업데이트 | 단순함, 낮은 오버헤드, 좋은 기본선 | cross-function 또는 read-only reentrancy를 항상 막진 못함 | 외부 호출이 있는 대부분의 상태 변경 함수 |
nonReentrant guard (mutex) |
실행 중 재진입을 차단 | 강력함, 명시적, 널리 사용 | 내부 함수 호출이 복잡해질 수 있음; 관련 진입점을 모두 커버해야 함 | 출금, 클레임, 대출, 청산 |
| Pull payments | 잔고를 크레딧으로 적립하고 나중에 출금 | 코어 로직의 외부 호출 최소화 | 사용자 추가 트랜잭션 필요; 출금은 여전히 guard 필요 | 보상, 수수료 분배, 환불 |
| Restricted external calls | 호출 가능한 컨트랙트를 제한 | 예상치 못한 콜백 감소 | composability 감소; governance 오버헤드 | admin 툴, upgrade hooks, strategy 실행 |
| Careful token/NFT handling | hooks를 재진입 벡터로 취급 | 콜백 관련 사고 예방 | 통합 표준에 대한 깊은 이해 필요 | NFT marketplace, ERC777/4626 연동 staking |
Audit 체크리스트: 실제 프로토콜에서 reentrancy를 찾고 테스트하는 방법
Reentrancy는 설계로 예방하지만, 증명은 테스트, 공격자 관점 리뷰, 그리고 모든 외부 상호작용 경로에 대한 타깃 감사로 합니다.
인용용(40–60 words):
Reentrancy를 탐지하려면 모든 외부 호출(ETH 전송, 토큰 전송, router/vault 상호작용, NFT safe transfer)을 식별한 뒤, 호출 전에 상태 업데이트와 invariant가 확정되는지 추적해야 합니다. 악성 receiver 컨트랙트와 fuzzing으로 테스트하세요. 특히 cross-function 공유 상태와 콜백 가능한 토큰에 주의해야 합니다.
실무 체크리스트(엔지니어 친화)
- 모든 외부 호출을 맵핑
call,transfer,send- 토큰
transfer/transferFrom safeTransferFrom(ERC721/1155)- vault/router/bridge 상호작용
- 여러 함수가 만지는 공유 상태를 찾기
- balances, shares, debt, reward indices, snapshots
- 순서 점검
- 핵심 상태 업데이트가 외부 호출 전에 끝나는가?
- invariants 추가
- total assets == sum of balances (또는 bounded)
- shares * price-per-share 일관성
- reward debt 단조성(monotonicity)
- 공격자 시뮬레이션
- 악성 receiver가 재진입
- hooks가 있는 악성 토큰
- flash-loan 기반 공격으로 영향 확대
예시: Cross-function reentrancy 함정(패턴)
withdraw()가 외부 호출을 하고, claimRewards()가 재진입 가능하며 “출금 이후” 상태를 가정한다면 다음이 발생할 수 있습니다:
- 이중 보상 클레임
- cooldown 우회
- reward debt 회계 붕괴
흔한 완화책은:
- 회계 업데이트를 내부 함수 하나로 중앙화
- 상태를 변경하는 모든 진입점에 nonReentrant를 일관되게 적용
- 외부 상호작용을 실행의 끝으로 격리
Soken이 보통 reentrancy를 리뷰하는 방식(기대할 점)
Soken 감사에서는 특히: - external call graph를 만들고 “reentry windows”를 주석 처리 - CEI 준수 및 mutex 커버리지 검증 - 콜백 지원 표준(ERC777, ERC721/1155 safe transfers, ERC4626 vaults) 통합을 대상으로 테스트 - Foundry에서 exploit PoC를 시도해 심각도를 검증하고 수정 사항을 확인
이는 공격자가 한 번의 원자적 트랜잭션에서 포지션 규모를 키우고 엣지 케이스를 압박할 수 있는 flash-loan-attacks-defi에 노출된 DeFi 설계에서 특히 중요합니다.
결론: Reentrancy는 예방 가능하다—composability를 전제로 엔지니어링한다면
Reentrancy는 DeFi가 기본적으로 composable하기 때문에 최상위 스마트 컨트랙트 리스크로 남아 있습니다. 당신이 호출하는 “외부 컨트랙트”는 사용자일 수도, hooks가 있는 토큰일 수도, vault나 router일 수도, 공격자가 통제하는 callback일 수도 있습니다. 해결책은 단일 트릭이 아니라, CEI 순서 준수, 진입점 가드, 외부 호출 최소화, 공격자 관점 테스트라는 규율 있는 엔지니어링입니다.
staking, lending, governance, bridges, 또는 자산을 이동시키는 어떤 컨트랙트를 배포하든, reentrancy는 마지막에 린트 경고로 처리할 문제가 아니라 첫날부터의 설계 제약으로 다뤄야 합니다.
Soken (soken.io)은 vaults, staking, governance, 복잡한 통합 전반에서 smart contract auditing & penetration testing과 DeFi security reviews로 팀이 프로덕션 시스템을 강화하도록 지원합니다. reentrancy 중심 리뷰(PoCs 및 Foundry 테스트 포함)가 필요하다면 soken.io로 연락해 감사를 예약하고, 더 높은 확신을 가지고 배포하세요.