Upgradeable Contract 킬 체인: Uninitialized Proxies가 DeFi의 $200M+ 반복 악몽이 된 이유
Source: Dev.to
왜 업그레이드 가능성은 양날의 검인가
거대한 TVL을 보유한 모든 DeFi 프로토콜은 업그레이드 가능한 계약을 사용합니다. 이는 선택 사항이 아니라, 버그를 패치하고, 기능을 추가하며, 긴급 상황에 대응할 수 있는 능력이 필요하기 때문입니다.
하지만 업그레이드 가능성은 장전된 총과 같으며, 안전 스위치는 누구도 인정하고 싶어 하지 않을 정도로 자주 눌립니다.
가장 위험한 패턴 하나
새로운 공격이 아니라 함수 호출 누락—특히 UUPS 혹은 Transparent 프록시 뒤에 있는 구현 계약을 초기화하는 것을 잊는 경우입니다.
그 한 번의 실수가 2017년 이후 2억 달러가 넘는 손실 및 아슬아슬한 상황을 직접 초래했습니다.
킬 체인 작동 원리
The proxy holds all state (storage, balances).
The implementation holds all logic.
When you call the proxy, it delegatecalls into the implementation,
executing its code in the proxy’s storage context.핵심 세부 사항
- 구현 계약은 생성자를 사용할 수 없습니다 (생성자는 구현의 저장소에 상태를 설정하므로 프록시의 저장소에는 영향을 주지 않음).
- 대신
initialize()함수를 사용합니다 – 하지만 생성자와 달리initialize()는 적절히 보호되지 않으면 누구든지 호출할 수 있습니다.
패턴 개요
| Proxy Type | Upgrade Logic Lives In | Risk When Uninitialized |
|---|---|---|
| Transparent | 프록시 계약 | 공격자는 구현을 통해 업그레이드할 수 없음 |
| UUPS | 구현 계약 | 공격자가 구현에서 upgradeToAndCall()을 호출 → 게임 오버 |
UUPS는 가스 효율이 높고 널리 채택되고 있지만, 구현이 초기화되지 않았을 때는 더 위험합니다. 업그레이드 함수가 구현에 존재하기 때문에, 구현의 소유권을 획득한 공격자는 원하는 어떤 것으로든 업그레이드할 수 있습니다.
초기화되지 않은 UUPS 프록시 악용
Step 1: Deploy proxy + implementation (implementation left uninitialized)
Step 2: Attacker calls initialize() directly on the implementation
Step 3: Attacker calls upgradeToAndCall() on the implementation
Step 4: Implementation self‑destructs
Step 5: (Alternative) Attacker upgrades to a malicious implementation원조 재난 – Parity
개발자가 Parity 라이브러리 계약에서 initialize()를 호출해 소유자가 된 뒤 kill()을 호출했습니다. 라이브러리가 self‑destruct되어 이를 의존하던 모든 멀티시그 지갑이 사용할 수 없게 되었습니다.
- 513,774 ETH 영구적으로 동결됨 – 오늘까지 동결 상태.
function initWallet(address[] _owners, uint _required) {
// No protection — called directly on the library
owner = _owners[0];
}
function kill(address _to) onlyOwner {
selfdestruct(_to);
}OpenZeppelin의 UUPS 버그 (v4.1.0–v4.3.1)
보안 연구원들은 이 버전들이 구현 계약을 기본적으로 초기화할 수 없도록 남겨두었다는 것을 발견했습니다. 이를 사용한 모든 프로젝트는 취약한 구현 계약을 가지고 있었습니다.
해결 방법 – 한 줄
// In the implementation’s constructor
_disableInitializers();KeeperDAO와 Rivermen NFT와 같은 프로젝트는 악용되기 전에 패치를 적용했지만, 모든 사람이 이 소식을 받지는 못했습니다.
실제 사례
| Project | What Happened | Outcome |
|---|---|---|
| Wormhole (post‑bug fix) | 초기화되지 않은 UUPS 구현이 남아 있음 | 공격자는 다음을 할 수 있었음: 1️⃣ initialize() 호출2️⃣ 자신의 Guardian 세트 설정 3️⃣ 악성 업그레이드 승인 4️⃣ 모든 브리지 자산 탈취 |
| Ronin Bridge | 초기화되지 않은 프록시 파라미터 | 1,200만 $ 손실 |
| Parity | 초기화되지 않은 라이브러리 계약 | 513,774 ETH 동결 |
Wormhole 사건은 1,000만 $ 현상금으로, 당시 가장 큰 버그 현상금이었습니다.
가장 중요한 한 줄의 코드
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract MyProtocol is UUPSUpgradeable, OwnableUpgradeable {
@custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // ) -> Result {
// …
Ok()
}
}
// In tests, ensure the upgrade authority is the expected multisig보안 테스트에서 업그레이드 권한을 EVM에서 프록시 관리자를 검증하듯이 검증하십시오.
TL;DR 체크리스트
- 구현 계약을 초기화되지 않은 상태로 두지 마세요.
- 구현체의 생성자에서
_disableInitializers()를 호출하세요. - 프록시 + 구현체를 원자적으로 배포하세요 (단일 트랜잭션).
- 배포 후 구현체가 재초기화될 수 없는지 확인하세요.
- OpenZeppelin의 업그레이드 도구를 사용해 스토리지 레이아웃 호환성을 확인하세요.
_authorizeUpgrade를 멀티시그 또는 타임락으로 보호하세요.- 비‑EVM 체인에서는 업그레이드 권한을 동일하게 엄격히 관리하세요.
fn test_upgrade_authority_is_multisig() {
// test body...
}이 버그 클래스가 사라지지 않는 세 가지 이유
- 구현이 안전해 보인다 — 프록시 뒤에 있어 개발자들은 직접적으로 접근하지 않을 것이라고 가정한다.
- 테스트 공백 — 팀은 프록시 경로만 테스트하고 구현을 직접 호출하는 테스트는 하지 않는다.
- 업그레이드 기억 상실 — 첫 배포는 안전하지만 v3, v4, v5로 업그레이드하면서 누군가가 새 구현에서
_disableInitializers()호출을 잊어버린다.
해결책은 지루하다: 체크리스트, 자동화, 그리고 마지막 배포가 올바르다고 절대 가정하지 않는 것.
DreamWork Security는 매주 DeFi 보안 연구를 발표한다. 취약점 분석, 감사 도구 가이드, Solana와 EVM 생태계 전반에 걸친 보안 모범 사례를 팔로우하세요.