2026년 재진입 공격: 여전히 수백만 달러를 빼앗는 이유와 AI 탐지법
출처: Dev.to
재진입은 스마트 컨트랙트 악용에서 가장 오래된 트릭이다. DAO가 2016년에 이 공격을 당했다. 이렇게 유명한 버그라면 이제는 사라졌을 것이라고 생각할 수 있다. 하지만 그렇지 않다. 프로토콜은 매년 수백만 달러를 재진입 공격으로 잃고 있다. 교과서적인 버전은 너무 쉬워서 공격자들은 오래전부터 교과서 버전을 사용하지 않는다.
이 글에서는 고전적인 버그와 2026년에도 여전히 위협이 되는 현대 변형들을 살펴본다: 함수 간, 컨트랙트 간, 뷰 함수에 의한 읽기 전용 재진입, 그리고 ERC777·ERC721을 통한 콜백 재진입. ReentrancyGuard가 대부분 개발자가 생각하는 만능 해결책이 아니라는 점과, LLM 기반 감사자가 호출‑후‑상태변경 패턴을 어떻게 패턴 매처가 잡지 못하는 방식으로 추론하는지 보여준다.
클래식: 단일 함수 재진입
다음은 전형적인 취약한 인출 함수이다.
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
balances[msg.sender] = 0; // 외부 호출 이후에 상태 업데이트
}
}
Enter fullscreen mode
Exit fullscreen mode
공격은 매우 단순하다. 공격자는 계약이다. withdraw가 msg.sender.call을 통해 ETH를 보낼 때, 제어권이 공격자의 receive() 함수로 넘어가고 아직 balances[msg.sender]가 0으로 초기화되지 않는다. 공격자는 다시 withdraw를 호출한다. 잔액은 여전히 원래 금액이므로 금고는 다시 지급한다. 금고가 비워질 때까지 반복한다.
contract Attacker {
VulnerableVault vault;
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw(); // 잔액이 0이 되기 전에 재진입
}
}
}
Enter fullscreen mode
Exit fullscreen mode
해결책은 체크‑이펙트‑인터랙션 패턴이다. 모든 검증을 먼저 수행하고, 상태를 업데이트한 뒤, 마지막에 외부 호출을 한다.
function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0; // 먼저 상태 변경
(bool success, ) = msg.sender.call{value: amount}(""); // 마지막에 외부 호출
require(success, "transfer failed");
}
Enter fullscreen mode
Exit fullscreen mode
이제 공격자가 재진입하더라도 잔액은 이미 0이므로 두 번째 지급은 0이 되고, 자금 고갈이 멈춘다. OpenZeppelin의 ReentrancyGuard를 추가할 수도 있다.
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}
Enter fullscreen mode
Exit fullscreen mode
재진입이 이 정도만 있었다면 문제는 해결된 것이었다. 전 세계 모든 린터가 외부 호출 뒤에 상태 쓰기를 경고한다. 하지만 수백만 달러가 아직 사라지는 이유는 현대 변형이 가드와 린터를 모두 피하기 때문이다.
함수 간 재진입
nonReentrant는 단일 함수만 잠근다. 하지만 컨트랙트는 여러 함수에 걸쳐 상태를 공유하고, 공격자가 다른 함수를 통해 같은 잔액을 읽어 재진입하면 잠금은 무용지물이다.
contract Vault {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0;
}
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount); // 여기서 잔액이 오래된 값일 수 있음
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
Enter fullscreen mode
Exit fullscreen mode
withdraw에는 가드가 있다. 하지만 외부 호출 중에 balances[msg.sender]는 아직 인출 전 전체 금액을 가지고 있다. 공격자의 receive()는 withdraw가 아니라 transfer를 호출한다. transfer는 같은 락으로 보호되지 않는다(기본 nonReentrant는 동일한 modifier 인스턴스를 공유하는 함수에만 재진입을 차단하고, 많은 코드베이스가 모든 함수에 이를 적용하지 않는다). 공격자는 아직 남아 있는 잔액을 두 번째 지갑으로 옮긴 뒤, withdraw가 끝나면서 원래 계정의 잔액을 0으로 만든다. 이제 자금을 두 번 보유하게 된다.
해결책은 동일한 근본 원칙이다: 외부 호출 전에 상태를 업데이트한다. withdraw가 먼저 잔액을 0으로 만들어야 한다. 가드는 보조 수단일 뿐, 실제 해결책은 아니다.
컨트랙트 간 재진입
이제 공유 상태가 두 개의 계약에 걸쳐 있다. 프로토콜 A가 회계 정보를 보관하고, 프로토콜 B가 자금을 보관한다. 한쪽에 걸린 락은 다른 쪽에 전혀 영향을 주지 않는다.
contract Pool {
Token public token;
mapping(address => uint256) public shares;
function redeem() external nonReentrant {
uint256 amount = shares[msg.sender];
token.transfer(msg.sender, amount); // ERC777 스타일 콜백이 여기서 발생
shares[msg.sender] = 0;
}
}
Enter fullscreen mode
Exit fullscreen mode
token이 수신자에게 훅을 호출한다면(아래에서 자세히 다룸), 공격자는 token.transfer 중에 재진입한다. redeem에 대한 가드 때문에 redeem 자체에 재진입할 수는 없지만, 다른 계약을 호출해 Pool.shares[msg.sender]를 자신이 보유한 양의 오라클로 신뢰하게 만들 수 있다. 그 두 번째 계약은 Pool이 실행 중이라는 사실을 전혀 알지 못한다. 아직 0이 아닌 오래된 shares 값을 읽고, 공격자는 그 값을 기반으로 차입하거나 민팅한다.
읽기 전용 재진입: 가드 하나로는 잡을 수 없는 경우
2022년 이후 가장 큰 피해를 입힌 변형이며, 설계상 ReentrancyGuard를 무력화한다.
nonReentrant는 상태를 변경하는 함수만 보호한다. 뷰 함수는 상태를 쓰지 않으므로 가드가 적용되지 않으며, 재진입해도 해가 없어 보인다. 문제는 뷰 함수가 외부 호출 중 일시적으로 일관성 없는 상태를 기반으로 값을 반환할 수 있다는 점이다.
예를 들어, 토큰 잔액과 총 공급량을 이용해 가격을 계산하는 AMM 스타일 풀을 보자.
contract StablePool {
uint256 public totalSupply;
function removeLiquidity(uint256 lpAmount) external nonReentrant {
uint256 ethOut = (lpAmount * address(this).balance) / totalSupply;
_burn(msg.sender, lpAmount);
(bool ok, ) = msg.sender.call{value: ethOut}(""); // 여기서 공격자가 재진입
totalSupply -= lpAmount; // 호출 중에는 아직 공급량이 감소되지 않음
}
function getVirtualPrice() external view returns (uint256) {
return (address(this).balance * 1e18) / totalSupply; // 일관성 없는 상태를 읽음
}
}
Enter fullscreen mode
Exit fullscreen mode
removeLiquidity가 진행되는 동안 ETH는 이미 call을 통해 계약을 떠났지만, totalSupply는 아직 감소되지 않았다. 외부 호출이 진행되는 그 짧은 시간 동안 getVirtualPrice()는 감소된 잔액을 변함없는 공급량으로 나누어 잘못된(낮은) 가격을 반환한다.
공격자의 receive()는 StablePool에 재진입하지 않는다. 대신 대출 프로토콜