재진입은 계약 A가 token 전송이나 ETH CALL로 외부 계약 B에 제어권을 넘긴 뒤, A가 중요한 상태를 갱신하기 전에 B가 A의 함수로 다시 들어와 같은 권리나 잔액을 재사용하는 공격입니다. 방어의 핵심은 입력·권한을 검사한 다음 잔액 차감과 청구 완료 같은 effects를 먼저 기록하고 마지막에 interaction을 수행하는 checks-effects-interactions입니다. ReentrancyGuard의 mutex는 같은 guard 영역 재호출을 막지만 unguarded 함수, 별도 계약 callback, read-only 관찰과 잘못된 상태 설계까지 자동으로 고치지 않습니다.
취약점은 외부 호출 전의 낡은 상태입니다
Vault가 balances[user]를 읽고 user.call{value:amount}를 실행한 뒤 잔액을 0으로 만든다고 하겠습니다. User가 contract면 receive 함수에서 Vault.withdraw를 다시 호출할 수 있습니다. 첫 frame이 아직 balance를 0으로 쓰지 않았으므로 두 번째 frame도 같은 amount를 통과해 반복 지급합니다.
ETH 전송만 위험한 것은 아닙니다. ERC-777 hook, NFT safeTransfer callback, lending protocol의 임의 token·oracle 호출도 control을 넘깁니다. 알려진 contract를 호출해도 그 contract가 다시 알 수 없는 contract를 호출할 수 있습니다. 외부 interaction 여부는 함수 이름이 아니라 실제 CALL 계열 trace로 판단합니다.
재진입의 본질은 두 번 호출됐다는 사실보다, 두 번째 호출이 첫 번째 호출의 완료 전 상태를 유효한 것으로 읽었다는 데 있습니다.
JOBCOIN 해설

그림은 이 주제의 공통 개념을 단순화한 설명입니다. 아래 항목에서 이 글의 구체적인 조건과 예외를 함께 읽어보세요.
- 취약점은 외부 호출 전의 낡은 상태입니다
Vault가 balances[user]를 읽고 user.
- Checks-Effects-Interactions로 순서를 뒤집습니다
안전한 withdraw는 먼저 amount와 권한을 검사하고 balances[user]=0을 저장한 뒤 외부 전송을 시도합니다.
- Mutex는 범위를 정확히 설정해야 합니다
OpenZeppelin ReentrancyGuard는 entered status를 설정하고 nonReentrant 함수가 끝난 뒤 복구합니다.
AI로 제작한 개념도 · 실제 가격·거래 내역·통계가 아닙니다.
Checks-Effects-Interactions로 순서를 뒤집습니다
안전한 withdraw는 먼저 amount와 권한을 검사하고 balances[user]=0을 저장한 뒤 외부 전송을 시도합니다. Callback이 다시 들어오면 이미 0인 상태를 보므로 지급 조건을 통과하지 못합니다. 외부 전송이 실패하면 require로 전체 transaction을 revert해 먼저 기록한 effect도 원복할 수 있습니다.
CEI는 단순 줄 순서가 아니라 invariant가 외부 호출 전에 성립하는지 보는 방법입니다. 총 부채, share supply, nonce와 claim flag처럼 함께 움직이는 값이 모두 갱신돼야 합니다. 한 변수만 먼저 바꾸고 다른 함수가 갱신 전 변수를 읽으면 cross-function reentrancy가 남습니다.
| 유형 | 재진입 경로 | 검토 항목 |
|---|---|---|
| 동일 함수 | withdraw→callback→withdraw | 잔액 선차감 |
| 교차 함수 | withdraw→callback→transfer | 공유 invariant |
| 교차 계약 | A→B→C→A | 전체 call graph |
| Read-only | callback 중 view 조회 | 중간 가격·비율 노출 |
| Callback token | transfer hook 재호출 | 지원 token semantics |
Mutex는 범위를 정확히 설정해야 합니다
OpenZeppelin ReentrancyGuard는 entered status를 설정하고 nonReentrant 함수가 끝난 뒤 복구합니다. 같은 guard를 공유하는 protected 함수끼리 중첩 호출할 수 없으므로 외부 entry 함수가 private worker를 호출하는 구조가 흔합니다. Upgradeable 환경에서는 선택한 구현의 초기 상태와 storage slot도 확인합니다.
Withdraw만 guard하고 같은 balances를 수정하는 borrow·transfer를 열어 두면 callback이 다른 entry point로 들어올 수 있습니다. 여러 contract가 각각 guard를 가져도 서로의 mutex는 공유하지 않습니다. Guard가 있다는 사실보다 어떤 invariant를 어느 entry points가 공유하는지 표로 만듭니다.
- 외부 CALL·token hook·callback 지점을 모두 표시합니다.
- 각 지점 직전의 미완료 state changes를 찾습니다.
- 공유 invariant를 쓰는 모든 public entry를 묶습니다.
- CEI와 mutex를 독립적으로 테스트합니다.
- 악성 receiver로 nested callback을 재현합니다.
Pull payment와 실패 처리를 함께 설계합니다
한 transaction에서 여러 사용자에게 ETH를 push하면 한 receiver의 revert가 전체 정산을 막거나 callback 면을 넓힙니다. 사용자별 credit을 먼저 기록하고 각 사용자가 별도 withdraw하게 하는 pull pattern은 외부 호출 범위를 작은 함수로 모읍니다. 그래도 withdraw 자체에는 CEI와 실패 반환 검사가 필요합니다.
Low-level call이 false를 반환했는데 무시하면 잔액만 차감하고 지급은 실패할 수 있습니다. 반대로 실패 시 credit을 다시 더하는 보상 로직이 callback과 얽히면 중복 credit을 만들 수 있습니다. 성공 조건과 revert 경계를 state machine으로 테스트합니다.
Gas 제한을 방어선으로 의존하지 않습니다
transfer의 2,300 gas stipend가 callback을 영원히 막는다는 가정은 opcode gas 변경과 receiver 동작 때문에 견고하지 않습니다. Solidity 문서도 Ether 전송은 code execution을 포함하며 call은 더 많은 gas를 넘겨 재진입 가능성을 연다고 설명합니다. Gas 제한 대신 상태 순서와 명시적 guard를 사용합니다.
테스트에는 동일 함수, 교차 함수, 여러 protocol을 거치는 callback, 실패·성공을 번갈아 내는 receiver를 포함합니다. Trace에서 depth만 세지 말고 각 frame이 읽고 쓴 slot과 외부 자산 이동을 대조해야 실제 invariant 손상을 확인할 수 있습니다.
자주 묻는 질문
nonReentrant modifier 하나면 모든 재진입이 막히나요?
해당 guard를 공유하는 함수 진입만 막습니다. Guard 밖 함수와 다른 계약의 상태, read-only 재진입은 별도 검토가 필요합니다.
View 함수도 재진입 위험이 있나요?
State를 쓰지 않아도 transaction 중간의 불완전한 가격·비율을 외부 protocol이 읽어 의사결정하면 read-only reentrancy 문제가 생길 수 있습니다.
ETH를 transfer로 보내면 안전한가요?
Gas stipend를 영구 방어선으로 의존하면 안 됩니다. CEI, pull payment와 반환값 검사를 적용해야 합니다.
더 깊이 읽기
본문에서 다룬 개념과 확인 절차를 다음 글에서 이어서 살펴보세요.



