개요
리엔트란시 재진입은 외부 호출 중 컨트랙트의 같은 함수 또는 다른 함수가 다시 호출되는 상황을 의미함 상태 변경 전 외부 호출이 발생하면 공격자가 재진입을 통해 상태 검증을 우회하거나 중복 실행을 유도할 수 있음 대표적 피해 사례로 The DAO 사건이 알려져 있음
실무 기본 원칙은 CEI 패턴 Checks-Effects-Interactions 준수와 ReentrancyGuard를 통한 보강 적용임 두 방법을 함께 쓰는 것이 표준에 가까움
유명한 사례와 최소 취약 패턴
취약한 순서 패턴 핵심
- 외부 전송 또는 외부 컨트랙트 호출이 상태 변경보다 먼저 발생
- 이후 상태 변경이 이루어져도 재진입 시점에는 이전 상태가 유효하여 중복 집행 가능
최소 취약 스니펫 예시
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient");
(bool ok, ) = msg.sender.call{value: amount}(""); // 외부 호출 선행
require(ok, "transfer failed");
balances[msg.sender] -= amount; // 상태 변경이 뒤늦게 발생
}공격자는 수신 훅에서 재호출하여 동일 검증을 반복 통과함
receive() external payable {
target.withdraw(1 ether); // 재진입 시도
}결과적으로 예치액 대비 과도한 출금이 가능해짐 Solidity 0.8+ 에서는 언더플로우 자체는 revert되나 핵심 피해는 차감 전 다중 송금이 이미 발생한 지점에 있음
공격 흐름 요약
- 1차 호출에서 require 통과 후 외부 전송 발생
- 수신 훅 receive 또는 fallback에서 동일 함수 또는 다른 취약 함수 재호출
- 상태 차감 전이라 동일 검증 재통과
- 반복 전송 발생 후 마지막에 상태 차감 또는 revert
- 최종적으로 컨트랙트 자금 고갈 또는 비정상 상태 유발
The DAO 2016 사례에서 대규모 자금 유출과 체인 하드포크까지 이어짐
방어 전략
방법 1 CEI 패턴 우선
상태 변경을 먼저 반영하고 외부 상호작용은 나중에 실행하는 패턴
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient"); // Checks
balances[msg.sender] -= amount; // Effects
(bool ok, ) = msg.sender.call{value: amount}(""); // Interactions
require(ok, "transfer failed");
}공격자가 재진입을 시도해도 이미 잔액이 차감되어 require 단계에서 즉시 차단됨
방법 2 ReentrancyGuard로 추가 안전벨트
OpenZeppelin ReentrancyGuard의 nonReentrant로 진입 중 재진입을 전역 플래그로 차단
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract C is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
// CEI와 함께 사용할 것을 권장
}
}nonReentrant는 함수 진입 시 상태를 ENTERED로 설정하고 종료 시 복구하는 간단한 락을 제공 재진입 시 즉시 revert 발생
방법 3 CEI + ReentrancyGuard 병행
- CEI 패턴으로 논리적 안전성 확보
- ReentrancyGuard로 실수와 교차 함수 재진입을 방어
- 코드 리뷰와 리팩터링 과정에서 순서가 어긋나는 사고를 줄이는 효과
ReentrancyGuard 동작 원리
내부 구조 요약
핵심은 단일 상태 변수로 진입 상태를 토글하는 락 패턴
uint256 private _status;
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
modifier nonReentrant() {
require(_status != _ENTERED, "reentrant call");
_status = _ENTERED; // 락
_; // 본문 실행
_status = _NOT_ENTERED; // 언락
}왜 0 대신 1과 2를 사용하나 가스 관점
SSTORE는 0에서 비영 값으로 변경 시 비용이 크게 발생하는 반면 비영 값에서 비영 값 변경은 상대적으로 저렴함
- 0 → 비영 값 전환 비용이 높음
- 비영 값 ↔ 비영 값 전환은 저렴 따라서 초기값을 1로 두고 1과 2 사이에서 토글하여 저장 비용을 줄이는 설계가 일반적임 네트워크 업그레이드에 따라 세부 비용 모델은 변동될 수 있으나 방향성은 동일함
실무 주의사항
교차 함수 재진입 가능성 인지
같은 함수만 위험한 것이 아님 다른 외부 함수가 동일한 공유 상태에 접근하면 재진입 벡터가 열림 두 함수 모두 nonReentrant 적용 또는 공유 상태 접근 순서를 CEI로 정렬 필요
nonReentrant 함수 간 내부 호출 금지 제약
nonReentrant가 적용된 함수에서 또 다른 nonReentrant 함수를 직접 호출하면 즉시 revert 발생 해결 패턴
function a() external nonReentrant {
_b(); // internal로 분리하여 호출
}
function _b() internal {
// 실제 로직 분리
}외부 진입점만 nonReentrant를 두고 내부 로직은 internal로 분리하는 계층화 권장
ERC20 대비 ERC721의 상대적 위험도 차이
- ERC20 전송 transfer 및 transferFrom은 표준 구현에서 외부 콜백이 없는 경우가 일반적이라 상대적으로 안전
- ERC721 safeTransferFrom은 수신자가 컨트랙트이면 onERC721Received 훅을 호출하므로 재진입 표면이 존재
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);NFT 마켓플레이스, 경매, 커스터디 로직 등에서는 nonReentrant 적용을 기본값으로 두는 편이 안전 커스텀 ERC20 구현에서 훅이나 외부 콜을 숨겨두는 경우도 있으므로 신뢰할 수 없는 토큰에 대해서는 보수적으로 대응 필요
언제 nonReentrant가 필요한가
꼭 필요한 경우
- ETH 전송 또는 낮은 수준 호출을 포함하는 함수
- safeTransferFrom 기반의 NFT 전송 로직
- 신뢰할 수 없는 외부 컨트랙트 호출 포함
- 공유 상태를 변경한 뒤 외부 상호작용이 이어지는 복합 트랜잭션 경로
조건부 적용
- 표준적 ERC20 전송만 포함된 경로
- 토큰 구현이 안전하고 외부 훅이 없음을 확신할 때는 생략 가능하나 방어적 적용을 고려
불필요한 경우
- view 또는 pure 함수
- 외부 호출이 전혀 없는 단순 상태 변경 함수
- private 또는 internal 전용 헬퍼 함수 자체에는 불필요하나 외부 진입점에서 보호해야 함
가스와 설계 트레이드오프
- CEI는 설계 원칙이며 추가 가스 비용 없이 얻는 이점이 큼
- ReentrancyGuard는 SLOAD SSTORE가 추가되어 소폭의 가스 비용 증가가 있으나 보안 이득이 훨씬 큼
- 1과 2를 사용하는 토글 방식으로 SSTORE 비용을 낮추는 최적화 적용
- 필요 함수에만 nonReentrant를 제한적으로 부여해 가스와 복잡도 균형 유지
테스트와 검증 포인트
- 재진입 시나리오를 흉내내는 테스트 컨트랙트로 receive fallback을 활용한 재호출 여부 검증
- 상태 변경 전 외부 호출이 없는지 정적 분석 및 리뷰 수행
- 다중 함수 경로 교차 검증 동일한 저장소에 접근하는 외부 함수 조합에서 재진입 가능성 체크
- 업그레이어블 패턴 사용 시 초기화 순서와 가드 상태 변수 초기값 검증
핵심 정리
- 1순위 CEI 패턴 적용 상태를 먼저 바꾸고 외부 상호작용은 나중에 배치
- 2순위 ReentrancyGuard로 재진입 락 추가 실수 방지와 교차 함수 재진입 차단
- nonReentrant 함수 간 직접 호출 금지 외부 진입점만 보호하고 내부로 로직 분리
- ERC721은 훅 존재로 위험도 높음 기본적으로 nonReentrant 적용 권장
- 가스 최적화는 1과 2 토글 사용 및 필요한 경로에만 가드 적용으로 균형 추구
한 줄 요약
코드 순서는 CEI로 지키고 재진입 락은 ReentrancyGuard로 걸어 이중 방어 구축