Ver4.0 이더리움 스마트 컨트랙트 보안 입문: Reentrancy(재진입) 공격의 원리와 실전 방어 기법 총정리

이더리움 스마트 컨트랙트 보안 입문: Reentrancy(재진입) 공격의 원리와 실전 방어 기법 총정리
"코드가 곧 법(Code is Law)"인 세계에서, 버그 한 줄은 곧 금융사고다."
The DAO부터 최근 DeFi 해킹까지 — 재진입 공격을 친구에게 설명하듯 풀어본다.
1. 들어가며: 왜 아직도 Reentrancy인가?
친구야, 솔직히 말해볼게. 블록체인 보안 얘기 나오면 제일 먼저 튀어나오는 단어가 뭘까?
십중팔구 Reentrancy(재진입) 공격이야.
2016년 The DAO 해킹으로 약 360만 ETH가 빠져나갔고, 그 사건 때문에 이더리움이 이더리움(ETH)과 이더리움 클래식(ETC)으로 쪼개졌어.
블록체인 역사상 가장 비싼 버그 중 하나였지.
그런데 웃긴 건, 그게 2016년 일인데 2023년, 2024년에도 재진입으로 털린 프로토콜이 계속 나온다는 거야.
Curve Finance의 Vyper 컴파일러 버그(2023년 7월, 약 6,000만 달러 규모 피해)도 결국 재진입 락이 제대로 작동하지 않은 문제였거든.
핵심 한 줄 요약
재진입 공격은 "장부를 정리하기 전에 돈부터 내주는" 코드의 실수를 파고드는 공격이야.
이 글에서는 EVM이 어떻게 동작하길래 이런 일이 생기는지, 공격 코드는 실제로 어떻게 생겼는지,
그리고 2025년 현재 표준으로 쓰이는 방어 패턴들이 뭔지 하나하나 뜯어볼 거야.
2. 기초 체력 다지기: EVM은 어떻게 돌아가나
2-1. 트랜잭션은 '하나의 실'로 돌아간다
이더리움 가상머신(EVM)은 싱글 스레드야. 동시에 두 개의 트랜잭션이 같은 컨트랙트 상태를 만지지 않아.
"어? 그럼 동시성 문제 없는 거 아냐?" 라고 생각할 수 있는데, 바로 여기가 함정이야.
트랜잭션 하나 안에서 컨트랙트 A가 컨트랙트 B를 호출하면, A의 실행은 잠시 멈추고 B가 실행돼.
그리고 B가 끝나야 A가 다시 이어서 실행되지. 마치 함수 호출 스택처럼.
문제는 B가 실행되는 동안 A의 상태(storage)는 "중간 상태"로 멈춰 있다는 거야.
B가 그 틈에 A를 다시 호출하면? A는 아직 업데이트되지 않은 옛날 장부를 보고 판단하게 돼.
비유로 이해하기 🍜
은행 창구에 갔어. 직원이 "100만원 인출하시죠" 하면서 현금부터 건네주고,
그 다음에 장부에 "잔액 0원"이라고 적으려고 해.
근데 현금을 받자마자 내가 "저 한 번 더 인출할게요!" 하고 소리치면?
직원은 아직 장부를 안 고쳤으니까 잔액을 100만원으로 보고 또 현금을 줘.
이걸 금고가 빌 때까지 반복하는 게 재진입 공격이야.
2-2. 이더를 보내는 세 가지 방법
솔리디티에서 이더를 보내는 방법은 크게 세 가지야. 이 차이를 모르면 재진입을 이해할 수 없어.
| 방식 | 가스 제한 | 실패 시 | 재진입 위험 |
|---|---|---|---|
transfer() | 2300 gas 고정 | revert | 낮음(단, 아래 주의) |
send() | 2300 gas 고정 | false 반환 | 낮음(반환값 체크 필수) |
call{value:x}("") | 제한 없음(전부 전달) | false 반환 | 높음 |
예전에는 "transfer() 쓰면 2300 가스밖에 안 줘서 재진입 못 해!"가 정답이었어.
근데 EIP-1884(이스탄불 하드포크, 2019)에서 SLOAD 가스 비용이 200에서 800으로 오르면서,
멀쩡한 스마트 컨트랙트 지갑(Gnosis Safe 같은 것들)이 2300 가스로는 이더를 못 받는 사태가 벌어졌어.
그래서 지금은 ConsenSys와 OpenZeppelin 모두 transfer() 대신 call을 쓰되, 재진입 방어를 코드 레벨에서 하라고 권장해.
가스 제한에 보안을 의존하는 건 하드포크 한 번이면 무너지는 모래성이거든.
3. 취약 코드 해부: 교과서적인 그 컨트랙트
자, 이제 진짜 코드를 보자. 아래는 재진입 설명할 때 전 세계가 쓰는 "국민 예제"야.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ⚠️ 절대 따라하지 말 것 (취약 코드)
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, "no balance");
// (1) 외부 호출 먼저 — 여기서 제어권이 넘어간다!
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
// (2) 상태 변경은 나중에 — 이미 늦었다
balances[msg.sender] = 0;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
얼핏 보면 멀쩡해 보이지? require도 있고 반환값 체크도 하고.
근데 순서가 잘못됐어. 돈을 먼저 보내고 장부를 나중에 정리하잖아.
3-1. 공격 컨트랙트는 이렇게 생겼다
contract Attacker {
VulnerableBank public target;
address public owner;
constructor(address _target) {
target = VulnerableBank(_target);
owner = msg.sender;
}
// 공격 시작: 1 ETH 예치 후 즉시 인출 시도
function attack() external payable {
require(msg.value >= 1 ether, "need 1 ETH");
target.deposit{value: 1 ether}();
target.withdraw();
}
// 🔁 이더를 받을 때마다 자동 실행되는 함수
receive() external payable {
if (address(target).balance >= 1 ether) {
target.withdraw(); // 재진입!
}
}
function collect() external {
payable(owner).transfer(address(this).balance);
}
}
포인트는 receive()야.
솔리디티 컨트랙트는 이더를 받으면 receive()(또는 fallback())가 자동으로 실행돼.
공격자는 여기에 "다시 인출해!"라는 명령을 심어놓은 거지.
실행 흐름을 따라가 보자
1. Attacker가 1 ETH 예치 → balances[Attacker] = 1 ETH
2. withdraw() 호출 → amount = 1 ETH
3. Bank가 Attacker에게 1 ETH 전송 → receive() 발동
4. receive()가 다시 withdraw() 호출
→ balances[Attacker]는 아직 1 ETH! (2번의 마지막 줄이 실행 안 됨)
5. 또 1 ETH 전송 → 또 receive() → 또 withdraw()...
6. Bank 잔액이 바닥날 때까지 반복 → 콜스택이 되감기며 balances[Attacker] = 0이 여러 번 실행되지만 이미 늦음
예치금 1 ETH로 컨트랙트에 있던 100 ETH를 다 가져가는 거야.
가스만 충분하면 반복 횟수는 사실상 제한이 없어. EVM 콜 깊이 제한(1024)은 있지만, 보통 가스가 먼저 떨어지지.
4. 재진입의 4가지 얼굴
"재진입 = 같은 함수 다시 호출"이라고만 알면 절반만 아는 거야.
실무에서 나오는 유형은 훨씬 다양해.
4-1. Single-Function Reentrancy
위에서 본 그 예제야. 같은 함수를 계속 재호출하는 가장 단순한 형태.
요즘 웬만한 자동 분석 도구(Slither, Mythril)가 다 잡아내.
4-2. Cross-Function Reentrancy
여기서부터 머리가 아파와. 다른 함수지만 같은 상태 변수를 공유하는 경우야.
contract CrossFuncVulnerable {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) public {
require(balances[msg.sender] >= amount);
balances[to] += amount;
balances[msg.sender] -= amount;
}
function withdraw() public {
uint256 amount = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] = 0; // 여전히 늦다
}
}
공격자는 withdraw() 도중에 transfer()를 호출해서
아직 0으로 안 바뀐 잔액을 공범 주소로 옮겨버려.
그럼 이더도 받고, 장부상 잔액도 보존되는 마법이 일어나지.
교훈: 재진입 락은 함수 하나가 아니라 "상태를 공유하는 함수 그룹 전체"에 걸어야 해.
4-3. Cross-Contract Reentrancy
컨트랙트 A의 상태를 컨트랙트 B가 읽어서 계산에 쓰는 구조에서 발생해.
A가 중간 상태일 때 B를 호출하면, B는 잘못된 전제로 계산하게 돼.
2022년 3월 Agave / Hundred Finance 사건이 대표적이야.
ERC-777 토큰의 tokensToSend 훅을 이용해서, 담보 가치가 차감되기 전에 다시 대출을 받아버린 케이스지.
Gnosis Chain에서 약 1,100만 달러 규모 피해가 났어.
4-4. Read-Only Reentrancy (읽기 전용 재진입)
이건 진짜 반전이야. 상태를 바꾸지 않는 view 함수가 공격 도구가 돼.
Curve 같은 AMM의 get_virtual_price()를 생각해봐.
유동성 제거(remove_liquidity) 도중에 이더를 사용자에게 먼저 보내는데,
그 시점에 LP 토큰은 이미 소각됐지만 풀의 자산 잔액은 아직 갱신 전인 순간이 있어.
이때 공격자가 get_virtual_price()를 호출하면 비정상적으로 부풀려진 가격이 나와.
그걸 오라클로 쓰는 대출 프로토콜은? 과도한 대출을 승인해버리지.
2023년 4월 Sentiment 프로토콜이 이 방식으로 약 100만 달러를 잃었어.
무서운 건, 피해 프로토콜 자체 코드는 재진입 락이 잘 걸려 있었다는 점이야.
외부 컨트랙트의 view 함수를 신뢰한 게 문제였던 거지.
5. 방어 기법 1: Checks-Effects-Interactions 패턴
가장 근본적이고, 가장 가스 효율적이고, 가장 우아한 해법이야.
이름 그대로 검사 → 상태 변경 → 외부 호출 순서를 지키는 거야.
// ✅ 안전한 버전
function withdraw() external {
// 1. CHECKS
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
// 2. EFFECTS ← 상태를 먼저 바꾼다
balances[msg.sender] = 0;
// 3. INTERACTIONS ← 그 다음에 돈을 보낸다
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
딱 두 줄 위치를 바꿨을 뿐인데 공격이 완전히 무력화돼.
재진입이 들어와도 balances[msg.sender]가 이미 0이니까 require에서 튕겨나가거든.
왜 이게 최선인가?
· 추가 storage 슬롯을 안 써서 가스 비용 0원
· 외부 라이브러리 의존 없음
· 코드를 읽는 사람이 흐름을 바로 이해함
OpenZeppelin도 "가능하면 CEI 패턴을 먼저 적용하고, 그래도 부족할 때 락을 추가하라"고 안내해.
6. 방어 기법 2: Reentrancy Guard (뮤텍스 락)
CEI를 지키기 어려운 복잡한 로직이 있어. 예를 들면 외부 호출 결과에 따라 상태를 결정해야 하는 경우.
그럴 땐 뮤텍스(mutex) 락을 걸어.
6-1. 직접 구현해보기
contract Guarded {
uint256 private _status = 1; // 1 = 미진입, 2 = 진입중
modifier nonReentrant() {
require(_status != 2, "ReentrancyGuard: reentrant call");
_status = 2;
_;
_status = 1;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] = 0;
}
}
왜 bool이 아니라 uint 1/2를 쓸까? 🤔
EVM에서 storage 값을 0에서 0이 아닌 값으로 바꾸는 건 20,000 gas(SSTORE cold, zero→nonzero),
0이 아닌 값에서 다른 0이 아닌 값으로 바꾸는 건 2,900~5,000 gas 수준이야.
false(0) ↔ true(1)로 토글하면 매번 비싼 비용을 내지만,
1 ↔ 2로 토글하면 훨씬 싸. OpenZeppelin이 이렇게 구현한 이유가 바로 이거야.
6-2. OpenZeppelin ReentrancyGuard 쓰기
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0; // CEI도 같이 적용
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
}
바퀴를 다시 발명하지 마. 검증된 라이브러리를 써.
OpenZeppelin Contracts는 수많은 감사와 실전 사용을 거친 사실상의 업계 표준이야.
6-3. Transient Storage 버전 (EIP-1153)
2024년 3월 Dencun(칸쿤-데네브) 업그레이드로 EIP-1153 Transient Storage가 도입됐어.TSTORE/TLOAD 오피코드인데, 트랜잭션이 끝나면 자동으로 초기화되는 임시 저장소야.
재진입 락에 딱 맞는 용도지. 비용이 100 gas 수준으로 훨씬 저렴해.
import "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
contract CheapGuard is ReentrancyGuardTransient {
function withdraw() external nonReentrant {
// 동일한 보호, 훨씬 적은 가스
}
}
| 구분 | 기존 ReentrancyGuard | ReentrancyGuardTransient |
|---|---|---|
| 저장 위치 | storage (영구) | transient storage (임시) |
| 대략적 오버헤드 | 수천 gas | 수백 gas 이하 |
| 요구사항 | 모든 EVM | Cancun 이후 EVM |
| 주의점 | - | 일부 L2 미지원 가능 |
배포 대상 체인이 Cancun EVM을 지원하는지 꼭 확인하고 써야 해.
L2마다 업그레이드 시점이 다르거든.
7. 방어 기법 3: Pull over Push (인출 패턴)
아예 돈을 "보내지" 말고, 받을 사람이 "가져가게" 하는 설계 철학이야.
contract PullPayment {
mapping(address => uint256) public pendingWithdrawals;
// 정산 로직: 실제 전송은 하지 않고 '권리'만 기록
function settle(address winner, uint256 prize) internal {
pendingWithdrawals[winner] += prize;
}
// 사용자가 직접 호출해서 가져감
function claim() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "nothing to claim");
pendingWithdrawals[msg.sender] = 0; // Effects
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
}
Pull 패턴의 장점
· 외부 호출 지점이 claim() 하나로 집중돼서 감사하기 쉬움
· 수신자가 컨트랙트라 전송이 실패해도 전체 로직이 멈추지 않음 (DoS 방지)
· 경매, 배당, 에어드랍 같은 다수 수령인 시나리오에 필수
실제로 King of the Ether 같은 초기 컨트랙트들은 push 방식 때문에
"이더를 거부하는 컨트랙트"가 왕좌를 차지하면 게임이 영구 정지되는 사고를 겪었어.
8. 실전 사례 분석: 돈이 사라진 순간들
8-1. The DAO (2016년 6월)
이더리움 역사 그 자체인 사건이야.
The DAO는 탈중앙 벤처펀드를 표방하며 1,200만 ETH 이상(당시 약 1.5억 달러)을 모았어.
문제의 함수는 splitDAO였어. DAO에서 나가면서 자기 몫의 이더를 가져가는 기능인데,
이더를 먼저 보내고 토큰 잔액을 나중에 0으로 만드는 전형적인 CEI 위반이었지.
해커는 약 360만 ETH를 자식 DAO로 빼돌렸고,
커뮤니티는 논쟁 끝에 하드포크로 거래를 되돌리기로 결정했어.
"코드가 법"이라는 원칙을 지켜야 한다고 반대한 쪽은 원래 체인에 남았고, 그게 이더리움 클래식(ETC)이야.
여담: The DAO 자금은 27일간 "자식 DAO"에 묶여 있게 설계되어 있었어.
그 유예 기간이 없었다면 대응조차 불가능했을 거야. 타임락의 중요성을 보여준 사례지.
8-2. Cream Finance (2021년 8월)
AMP 토큰이 ERC-777 표준을 따랐다는 게 화근이었어.
ERC-777은 토큰 전송 시 수신자에게 tokensReceived 콜백을 보내는데,
이게 사실상 "토큰 전송할 때마다 재진입 기회를 제공"하는 거였거든.
공격자는 담보를 빌리는 도중 콜백으로 재진입해서 같은 담보로 두 번 대출을 받았어.
피해액은 약 1,880만 달러.
ERC-777 / ERC-1155 / ERC-721 주의보 ⚠️
· ERC-777: tokensToSend, tokensReceived 훅
· ERC-721: safeTransferFrom → onERC721Received 콜백
· ERC-1155: onERC1155Received 콜백
이 콜백들은 모두 외부 코드 실행 지점이야.
NFT 민팅 함수에서 _safeMint를 쓴 뒤 발행량을 증가시키면?
공격자가 콜백에서 재진입해 1인 1개 제한을 뚫고 수십 개를 민팅할 수 있어.
(실제로 여러 NFT 프로젝트가 이 방식으로 당했어.)
8-3. Curve Finance (2023년 7월)
이건 좀 다른 층위의 사건이야. 개발자 코드가 아니라 컴파일러 자체의 버그였거든.
Vyper 컴파일러 0.2.15, 0.2.16, 0.3.0 버전에서@nonreentrant 데코레이터가 제대로 작동하지 않았어.
서로 다른 키를 가진 락들이 같은 storage 슬롯을 공유해버리거나, 락이 아예 해제되는 문제였지.
결과적으로 pETH/ETH, msETH/ETH, alETH/ETH, CRV/ETH 풀들이 털렸고
총 피해 규모는 6,000만 달러 이상으로 집계됐어.
여기서 얻는 교훈:
"우리는 nonReentrant 붙였으니 안전해"는 위험한 생각이야.
컴파일러 버전, 라이브러리 버전까지 보안 범위에 포함시켜야 해.
그리고 방어는 항상 다층(defense in depth)으로.
8-4. 읽기 전용 재진입 사례들
2022년 Curve의 잠재적 취약점이 화이트햇에 의해 공개된 이후,
이를 응용한 공격이 줄줄이 터졌어.
| 프로젝트 | 시기 | 대략 피해액 | 핵심 원인 |
|---|---|---|---|
| Sentiment | 2023.04 | 약 100만 달러 | Balancer 풀 read-only 재진입 |
| Midas Capital | 2023.01 | 약 66만 달러 | Curve LP 가격 조작 |
| dForce | 2023.02 | 약 365만 달러 | Curve 오라클 재진입 (반환됨) |
| Conic Finance | 2023.07 | 약 320만 달러 | Curve 메타풀 재진입 |
패턴이 보이지? "내 코드는 안전한데 내가 참조하는 남의 코드가 중간 상태를 노출했다"는 거야.
DeFi의 레고 조립(composability)이 양날의 검인 이유지.
9. 읽기 전용 재진입, 어떻게 막나
이건 방어가 까다로워. 내 컨트랙트에 락을 아무리 잘 걸어도 소용없거든.
방법 1: 락 상태 조회하기
Curve 풀은 claim_admin_fees() 같은 함수에 @nonreentrant('lock')이 걸려 있어.
오라클로 가격을 읽기 전에 이 함수를 호출해서 락 상태를 확인하는 트릭을 쓸 수 있어.
풀이 재진입 중이면 revert가 발생하니까.
function safeGetPrice(ICurvePool pool) internal returns (uint256) {
// 락이 걸려있으면 여기서 revert → 중간 상태 가격 차단
pool.claim_admin_fees();
return pool.get_virtual_price();
}
방법 2: 자체 산출 오라클 사용
외부 LP 토큰 가격을 그대로 믿지 말고,
기초 자산 가격 × 풀 구성비를 직접 계산하는 "페어 LP 프라이싱" 방식을 써.
Chainlink의 개별 자산 피드를 조합하는 방식이 대표적이야.
방법 3: TWAP과 신선도 검증
순간 가격(spot price) 대신 시간 가중 평균 가격(TWAP)을 쓰면
단일 트랜잭션 내 조작에 훨씬 강해져.
여기에 가격 변동폭 상한(circuit breaker)까지 두면 금상첨화.
DeFi 리스크 관리 체크리스트 📋
① 외부 프로토콜의 view 함수를 신뢰하기 전에 그 프로토콜 코드를 직접 읽었는가?
② 가격 소스가 단일 지점인가, 복수 소스 교차 검증인가?
③ 비정상 가격 감지 시 자동 정지(pause) 메커니즘이 있는가?
④ 새로운 담보 자산 추가 시 별도 보안 검토 프로세스가 있는가?
10. 개발자 실전 체크리스트
10-1. 코드 작성 단계
✅ 반드시 지킬 것
1. CEI 패턴을 기본값으로. 예외가 필요하면 주석으로 이유를 남겨라.
2. 외부 호출은 함수의 마지막에 배치.
3. 자금이 오가는 모든 public/external 함수에 nonReentrant 검토.
4. 상태를 공유하는 함수들은 같은 락으로 묶어라.
5. _safeMint, safeTransferFrom 사용 전후 상태 갱신 순서를 재확인.
6. 반환값 체크 없는 call 금지. (bool ok, ) = ...; require(ok);
7. 가스 제한(2300)에 보안을 의존하지 말 것.
⚠️ 흔한 실수들
· nonReentrant를 view 함수엔 못 붙인다고 그냥 방치
· 배치(batch) 함수에 락을 안 걸어서 내부 루프로 우회당함
· 프록시 업그레이드 시 storage 슬롯 충돌로 락 변수가 깨짐
· 상속받은 부모 컨트랙트의 함수에 락이 없음
· 테스트넷에서만 검증하고 포크 테스트(mainnet fork)를 안 함
10-2. 검증 도구
| 도구 | 유형 | 특징 |
|---|---|---|
| Slither | 정적 분석 | 빠름, CI 통합 쉬움, 재진입 탐지기 3종 내장 |
| Mythril | 심볼릭 실행 | 경로 탐색 기반, 느리지만 깊이 있음 |
| Echidna | 퍼징 | 속성 기반 테스트, 불변식 위반 탐색 |
| Foundry (forge) | 테스트/퍼징 | invariant 테스트, mainnet fork 지원 |
| Certora | 형식 검증 | 수학적 증명, 대형 프로토콜에서 사용 |
# Slither로 빠르게 스캔
slither . --detect reentrancy-eth,reentrancy-no-eth,reentrancy-benign
# Foundry로 공격 시나리오 테스트
forge test --match-test testReentrancyAttack -vvvv
10-3. 공격 테스트 코드 예시
// Foundry 테스트
contract ReentrancyTest is Test {
SafeBank bank;
Attacker attacker;
function setUp() public {
bank = new SafeBank();
vm.deal(address(this), 100 ether);
bank.deposit{value: 50 ether}();
attacker = new Attacker(address(bank));
}
function testReentrancyBlocked() public {
vm.deal(address(attacker), 1 ether);
vm.expectRevert(); // 공격이 실패해야 정상
attacker.attack{value: 1 ether}();
assertEq(address(bank).balance, 50 ether);
}
}
보안은 "안 털리는 코드를 짜는 것"이 아니라 "털리는지 직접 시도해보는 것"이야.
공격자 컨트랙트를 직접 작성해서 테스트 스위트에 넣어둬.
11. 감사(Audit)와 운영 관점
11-1. 감사는 만능이 아니다
"감사 받았으니 안전해요!"라는 말, 조심해야 해.
Cream Finance도, Curve 관련 프로토콜들도 감사를 받았었어.
감사는 특정 시점의 특정 코드에 대한 스냅샷일 뿐이야.
배포 후 파라미터 변경, 새 담보 자산 추가, 외부 프로토콜 통합 같은 건 감사 범위 밖이지.
11-2. 운영 단계 방어선
다층 방어 구성 🛡️
1층 — 코드: CEI + ReentrancyGuard + 최소 권한 원칙
2층 — 아키텍처: 자금 분리, 출금 한도, 타임락
3층 — 모니터링: 온체인 이상 거래 실시간 감지
4층 — 대응: Pause 기능, 멀티시그 긴급 대응팀
5층 — 보상: 버그 바운티 프로그램 운영
특히 출금 한도(withdrawal rate limit)는 과소평가된 방어책이야.
"1시간에 TVL의 5% 이상은 못 빠져나간다"는 규칙만 있어도
공격이 발생했을 때 대응할 시간을 벌 수 있어.
11-3. 핀테크 관점에서 보기
전통 금융과 비교하면 이해가 빨라.
은행 코어뱅킹 시스템은 ACID 트랜잭션과 락 관리가 DB 레벨에서 보장돼.
개발자가 실수해도 DBMS가 어느 정도 막아주지.
반면 스마트 컨트랙트는 그 모든 걸 애플리케이션 코드가 직접 책임져야 해.
게다가 배포 후엔 수정도 어렵고(불변성), 공격자는 24시간 전 세계에서 소스코드를 들여다보고 있어.
| 항목 | 전통 금융 시스템 | 스마트 컨트랙트 |
|---|---|---|
| 동시성 제어 | DBMS가 보장 | 개발자가 직접 구현 |
| 롤백 | 운영자 개입 가능 | 원칙적으로 불가 |
| 코드 공개 | 비공개 | 완전 공개 |
| 패치 | 즉시 배포 | 프록시/거버넌스 필요 |
| 사고 보상 | 예금보험·규제 | 자체 보험 or 손실 |
그래서 블록체인 개발자에게 요구되는 보안 수준은 일반 웹 개발보다 한참 높아.
버그 하나가 곧바로 금전 손실이고, 되돌릴 수 없으니까.
이런 실무 감각은 문서만 읽어서는 잘 안 붙어.
재능넷 같은 재능 공유 플랫폼에서 실제 감사 경험이 있는 개발자에게
코드 리뷰를 받아보거나, 스마트 컨트랙트 보안 멘토링을 받는 것도 좋은 방법이야.
혼자 공부하다 놓치는 부분을 실전 경험자가 짚어주는 건 확실히 다르거든.
12. 심화: 락이 뚫리는 경우들
"nonReentrant 붙였는데 왜 털려?" 하는 상황들이 실제로 있어.
12-1. 델리게이트콜과 storage 충돌
프록시 패턴에서 구현 컨트랙트를 업그레이드할 때,
_status 변수가 위치한 storage 슬롯이 바뀌면 락이 무력화될 수 있어.
OpenZeppelin의 Upgradeable 버전(ReentrancyGuardUpgradeable)은
ERC-7201 네임스페이스드 스토리지를 써서 이 문제를 해결했어.
업그레이드 가능한 컨트랙트라면 반드시 Upgradeable 버전을 써야 해.
12-2. 다른 컨트랙트의 락은 내 락이 아니다
Vault와 Strategy가 분리된 구조를 생각해봐.
Vault의 withdraw()에 락이 걸려 있어도,
Strategy를 통해 우회 경로로 들어오면 Vault의 락은 그대로 통과돼.
해결책은 전역 락(global reentrancy guard)이야.
Balancer V2 같은 프로토콜은 Vault 단일 진입점에 전역 락을 걸어서 이 문제를 해결했어.
12-3. 락 안에서 락 걸린 함수 호출
반대로 너무 광범위하게 락을 걸면 정상적인 내부 호출도 막혀서 기능이 깨져.nonReentrant 함수 안에서 다른 nonReentrant 함수를 호출하면 revert되거든.
// ❌ 이러면 항상 revert
function a() public nonReentrant { b(); }
function b() public nonReentrant { /* ... */ }
// ✅ 내부 로직을 private으로 분리
function a() public nonReentrant { _core(); }
function b() public nonReentrant { _core(); }
function _core() private { /* 공통 로직 */ }
이런 구조 설계가 생각보다 자주 발목을 잡아.
처음부터 "외부 진입점"과 "내부 로직"을 분리하는 습관을 들이자.
13. 마무리: 보안은 습관이다
여기까지 왔으면 이제 재진입 공격은 꽤 잘 이해했을 거야. 정리해보자.
오늘의 핵심 정리 📌
① 재진입은 상태 변경 전에 외부 호출을 해서 생기는 문제다.
② Checks-Effects-Interactions가 1순위 방어책이다. 공짜에 확실하다.
③ ReentrancyGuard는 보조 방어선. 상태 공유 함수는 같은 락으로 묶어라.
④ 유형은 Single / Cross-Function / Cross-Contract / Read-Only 네 가지.
⑤ ERC-777, ERC-721/1155 콜백은 숨은 외부 호출 지점이다.
⑥ 가스 제한(transfer의 2300)에 의존하지 마라. 하드포크로 깨진다.
⑦ 외부 프로토콜의 view 함수도 조작될 수 있다.
⑧ EIP-1153 Transient Storage로 락 비용을 크게 줄일 수 있다.
⑨ 감사는 스냅샷일 뿐. 운영 단계 방어선을 갖춰라.
⑩ 공격 테스트를 직접 작성해서 CI에 넣어라.
솔직히 말해서, 재진입은 이론적으로는 20년 된 개념이야.
운영체제 수업에서 배우는 임계 구역(critical section) 문제랑 본질이 같거든.
그런데도 계속 사고가 나는 이유는, 개발자들이 "지금 이 줄에서 제어권이 남에게 넘어간다"는 걸
직관적으로 인지하기 어렵기 때문이야. 코드는 위에서 아래로 쭉 읽히니까.
그래서 습관이 중요해.
"외부 호출이 보이면 일단 멈추고, 그 위에 있는 상태 변경이 다 끝났는지 확인한다."
이 한 가지만 몸에 배어 있어도 대부분의 재진입 사고는 막을 수 있어.
블록체인 금융은 코드가 곧 인프라이자 규제이자 신뢰야.
Web2에서는 버그가 "불편"이지만, Web3에서는 버그가 곧 "손실"이지.
그만큼 책임도 크고, 잘 해냈을 때의 가치도 커.
오늘 배운 내용을 실제 코드에 적용해보고, 직접 공격 컨트랙트를 짜서 테스트해보길 강력 추천할게.
더 깊이 파고들고 싶다면 재능넷의 지식인의 숲에서 다른 블록체인 보안 콘텐츠도 찾아보고,
필요하면 실무 전문가의 도움을 받는 것도 훌륭한 선택이야.
자, 이제 네 컨트랙트 코드를 열어서 call{value: 를 검색해봐.
그 아래에 상태 변경 코드가 있다면? 지금 바로 순서를 바꿀 시간이야. 🚀
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.