지난 시리즈에서 다양한 프록시 패턴에 대해 알아보았다. 프록시 패턴은 이제 스마트 컨트랙트의 불변성을 극복하고 지속 가능한 서비스를 구축하기 위해 표준 아키텍처로 자리잡았다. 그러나 이러한 유연성은 새로운 차원의 복잡성과 공격 벡터를 동반한다. 프록시는 단순한 호출 전달자를 넘어, 상태, 권한, 그리고 로직의 경계가 얽히는 미묘한 지점들을 만들어낸다.
이번 글에서는 프록시에서 발생할 수 있는 다양한 유형의 취약점에 대해 소개하고, Solodit을 통해 실제 보안 감사 과정에서 발견된 취약점 사례를 분석하여 업그레이더블 패턴을 채택할 때 반드시 고려해야 할 보안 함정들을 조명하고자 한다.
1. 접근 제어
프록시 패턴의 보안은 업그레이드 함수에 대한 접근 제어에서 시작된다. 만약 이 막강한 권한이 적절히 통제되지 않는다면, 시스템 전체의 소유권이 탈취되는 것은 시간 문제이다.
다음은 UUPS 프록시 패턴에서 업그레이드함수에 대한 접근 제어를 소홀히 한 사례이다.
UUPS 프록시 패턴의 핵심은 구현 컨트랙트 자체에 업그레이드 로직(upgradeToAndCall)이 포함되는 점이다. 이 함수의 실행은 _authorizeUpgrade 내부 훅을 통해 반드시 인가된 주체에게만 허용되어야 한다.
function upgradeToAndCall(address newImplementation, bytes memory data) public payable virtual onlyProxy {
_authorizeUpgrade(newImplementation);
_upgradeToAndCallUUPS(newImplementation, data);
}
Solidity
복사
하지만 MorpheusAI의 DistributionV2는 openzeppelin의 UUPSUpgradeable을 상속하면서도 _authorizeUpgrade함수를 아래와 같이 비워두었다. upgradeToAndCall 함수는 프록시에 새로운 구현 컨트랙트를 지정하는것 뿐만 아니라, data를 전달해 구현 컨트랙트로 임의의 함수를 호출할 수 있다.
contract DistributionV2 is UUPSUpgradeable {
IDistribution.Pool[] public pools;
// ...
function createPool(IDistribution.Pool calldata pool_) public {
pools.push(pool_);
}
// ...
>> function _authorizeUpgrade(address) internal view override {}
}
Solidity
복사
즉 이러한 구현은 누구에게나 시스템의 두뇌를 교체할 수 있는 권한을 부여한 것과 같다. 누구나 프록시의 권한으로 임의의 delegatecall을 실행하거나 원하는 구현 컨트랙트로 시스템을 변경할 수 있다.
공격자는 다음과 같은 절차로 시스템을 영구적으로 파괴하거나 자산을 탈취할 수 있다.
1.
공격자는 selfdestruct 또는 자금 탈취 로직이 포함된 악의적인 구현 컨트랙트를 배포한다.
2.
upgradeToAndCall 함수를 호출하여 프록시가 이 악성 컨트랙트를 새로운 구현체로 지정하도록 한다._authorizeUpgrade가 비어있으므로 이 호출은 아무런 제약 없이 성공한다.
3.
이후 프록시를 통해 악성 함수를 호출하면, 그 로직은 프록시의 컨텍스트에서 실행된다. 이는 프록시 자체의 파괴 또는 프록시가 보유한 모든 자산의 탈취로 이어질 수 있다.
이 글을 쓰는 시점에 selfdestruct는 더이상 바이트코드를 삭제하지는 않지만, 여전히 프록시의 모든 자산을 탈취할 수 있는 위험이 있다. 또한 꼭 selfdestruct가 아니라도 예상할 수 없는 로직이 포함된 컨트랙트가 프록시의 구현체로 설정되는 것은 매우 위험하다.
패치
MorpheusAI 팀은 OwnableUpgradeable을 추가로 상속하고, _authorizeUpgrade 함수에 onlyOwner 제어자를 적용하여 오직 지정된 소유자만이 업그레이드를 수행할 수 있도록 조치했다.
function _authorizeUpgrade(address) internal view override onlyOwner {
require(!isNotUpgradeable, "DS: upgrade isn't available");
}
Solidity
복사
2. 스토리지 충돌
업그레이드 가능한 프록시 패턴에서 스토리지 충돌은 골치 아픈 문제다. 프록시의 상태는 유지된 채 구현체만 교체되기 때문에, 새로운 구현체는 기존의 스토리지 레이아웃을 반드시 그대로 따라야 한다. 프록시에서 가장 중요한 상태변수인 _implementation과 owner가 업그레이드 과정에서 이상한 값으로 덮어씌워진다고 생각해보라. 어떤 일이 일어날 지 예상되지 않는가?
아래 두 예시는 각각 UUPS와 Diamond 패턴에서 발생한 스토리지 충돌이다.
Ethos 컨트랙트는 UUPS 패턴을 구현하며 AccessControl, SignatureControl 등 다수의 컨트랙트를 상속하는 복잡한 구조를 가졌다. 문제는 이 부모 컨트랙트들이 업그레이드 안전성(Upgrade Safety)을 전혀 고려하지 않고 설계되었다는 점이다.
1.
Upgradeable 라이브러리 미사용: Pausable과 같이 상태 변수를 갖는 OpenZeppelin 컨트랙트를 상속할 때, PausableUpgradeable이 아닌 일반 버전을 사용했다.
2.
gap 변수 부재: AccessControl 및 부모 컨트랙트들에 향후의 변수 추가에 대비한 스토리지 예약 공간(__gap)을 두지 않았다.
abstract contract AccessControl is IPausable, Pausable, AccessControlEnumerable, SignatureControl {
bytes32 public constant OWNER_ROLE = keccak256("OWNER_ROLE");
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
IContractAddressManager public contractAddressManager;
Solidity
복사
abstract contract SignatureControl is Initializable {
address public expectedSigner;
address public signatureVerifier;
mapping(bytes => bool) public signatureUsed;
// ...
}
Solidity
복사
업그레이드 전에는 변수들이 순차적으로 스토리지 슬롯에 저장되어 있다. 이 상태에서 부모 컨트랙트 중 하나라도 상태 변수가 추가되는 업그레이드를 진행한다면, 전체 상속 체인의 스토리지 슬롯이 하나씩 밀려나게 된다.
SignatureControl을 V2로 업그레이드하면서 signatureVerifier 뒤에 address public newVerifier;를 추가했다고 가정해보자.
업그레이드 전 스토리지의 상태는 편의를 위해 mapping을 제외하고 모든 변수가 순차적으로 저장된다고 생각하면 스토리지 레이아웃의 대략적인 상태는 다음과 같을 것이다(constant변수는 스토리지 슬롯을 차지하지 않으며 mapping은 키 값의 해시를 슬롯 주소로 사용한다).
슬롯 | 저장되는 변수(컨트랙트) | 데이터 타입 |
0 | _initialized, _initializing (Initializable) | bool 등 |
1 | expectedSigner (SignatureControl) | address |
2 | signatureVerifier (SignatureControl) | address |
3 | _paused (Pausable) | bool |
4 | contractAddressManager (AccessControl) | address |
V2로 업그레이드한 후에는 newVerifier가 원래 데이터가 있는 슬롯을 사용하면서 그 뒤의 슬롯이 모두 밀린다. V1 프록시의 3번 슬롯에는 _paused의 bool 값이 저장되어 있었지만, V2 로직은 이 슬롯을 newVerifier의 address 값으로 해석한다. 이처럼 스토리지 충돌은 상태 변수의 영구적인 손상을 유발하며, 이는 예측 불가능한 버그와 새로운 취약점으로 이어진다.
슬롯 | 저장되는 변수(컨트랙트) | 데이터 타입 |
0 | _initialized, _initializing (Initializable) | bool 등 |
1 | expectedSigner (SignatureControl) | address |
2 | signatureVerifier (SignatureControl) | address |
3 | _paused(Pausable) newVerifier | bool address |
4 | contractAddressManager(AccessControl) paused | address bool |
5 | contractAddressManager (AccessControl) | address |
스토리지 충돌을 예방하기 위해서는 스토리지 네임스페이스(EIP-7201)를 사용하거나, 각 컨트랙트의 상태변수를 일정한 간격을 두고 스토리지에 저장하도록 gap을 둬야 한다. 또한 업그레이드 가능한 프록시와 호환되지 않는 Pausable 및 AccessControlEnumerable대신 Openzeppelin-upgradeable에 속하는 라이브러리를 사용해야 한다(PausableUpgradeable, AccessControlEnumerableUpgradeable).
다수의 Facet이 하나의 프록시 스토리지를 공유하는 Diamond 패턴에서는 스토리지 관리가 더욱 중요하다. LiFinance는 Facet(DeManagerFacet.sol)과 Helper 컨트랙트(Swapper.sol, SwapperV2.sol)에서 LibStorage internal appStorage;라는 동일한 이름의 구조체를 사용하여 의도적으로 스토리지를 공유했다. 이들의 스토리지 레이아웃이 같기 때문에 문제가 되지 않을 것이라고 생각할 수 있지만, 이 구조는 한 Facet이 appStorage의 값을 변경하면 다른 Facet에 즉시 영향을 미치는 문제점을 갖고 있다.
appStorage는 접근제어와 관련된 변수도 갖고 있는데, 만약 접근제어 변수를 Swapper Facet이 다른 목적으로 덮어쓴다면, 해당 변수를 사용하는 다른 곳의 핵심적인 접근 제어 로직이 무력화될 수 있는 심각한 위험이 존재한다.
```solidity
contract DexManagerFacet {
...
LibStorage internal appStorage;
...
}
contract Swapper is ILiFi {
...
LibStorage internal appStorage; // overlaps with DexManagerFacet which is intentional
...
}
contract SwapperV2 is ILiFi {
...
LibStorage internal appStorage;
...
}
```
Solidity
복사
패치
LiFinance는 각 데이터의 목적에 맞게 LibAllowList와 같은 별도의 라이브러리를 정의하여, 스토리지 직접 접근 대신 라이브러리 함수를 통해 상태를 변경하도록 리팩토링했다. 이는 각 상태 변수의 책임과 경계를 명확히 하여 의도치 않은 충돌을 방지하는 효과적인 방법이다.
이외에도 Facet들의 상태변수를 스토리지 네임스페이스(EIP-7201)를 이용해 고유한 위치로 지정하는 방법도 있다.
...
+ import { LibAllowList } from "../Libraries/LibAllowList.sol";
...
@@ -18,10 +17,6 @@ contract DexManagerFacet {
...
/// Storage ///
- LibStorage internal appStorage;
...
- mapping(address => bool) storage dexAllowlist = appStorage.dexAllowlist;
- if (dexAllowlist[_dex]) return;
+ LibAllowList.addAllowedContract(_dex);
Diff
복사
DexManagerFacet.sol
...
+ import { LibAllowList } from "../Libraries/LibAllowList.sol";
...
if (
- !(appStorage.dexAllowlist[currentSwapData.approveTo] &&
- appStorage.dexAllowlist[currentSwapData.callTo] &&
- appStorage.dexFuncSignatureAllowList[bytes4(currentSwapData.callData[:4])])
+ !(LibAllowList.contractIsAllowed(currentSwapData.approveTo) &&
+ LibAllowList.contractIsAllowed(currentSwapData.callTo) &&
+ LibAllowList.selectorIsAllowed(bytes4(currentSwapData.callData[:4])))
) revert ContractCallNotAllowed();
Diff
복사
SwapperV2.sol
3. 악의적인 delegatecall 실행 가능성
프록시 컨트랙트는 사용자에게서 콜데이터를 받아 구현 컨트랙트로 임의의 트랜잭션을 ‘자신의 컨텍스트’로 실행시키기 때문에 그 어떠한 경우에도 악의적인 delegatecall이 실행되지 않도록 방지하는 것이 중요하다.
아래 사례는 Diamond 패턴에서 diamondCut을 통해 악의적인 호출이 일어나지 않도록 예방책을 구현했지만 특정 상황에서 예방책을 우회할 수 있음을 입증함으로써 촘촘한 방어의 필요성을 보여준다.
zkSync는 Facet을 변경하는 diamondCut함수가 악의적으로 이용되지 않도록 타임락을 통해 제안과 실행을 분리했다. diamondCut의 내용을 제안하는 것은 거버넌스로, 이들은 diamondCut을 제안하고 실행 대기 기간(upgradeNoticePeriodPassed)이 지날 때까지, 혹은 security council 멤버가 제안을 통과시킬 때까지 기다려야 한다.
require(approvedBySecurityCouncil || upgradeNoticePeriodPassed, "a6"); // notice period should expire
require(approvedBySecurityCouncil || !diamondStorage.isFrozen, "f3");
Solidity
복사
그러나 제안의 무결성을 검증하는 해시 계산(proposedDiamondCutHash)에 _calldata가 의도적으로 제외되었다. 이는 거버너에게 업그레이드를 제안하는 시점과 실제로 실행하는 시점 사이에 _calldata를 변경할 수 있는 어느 정도의 자유도를 부여하기 위함이었지만, ‘제안된 내용’과 ‘실제로 실행되는 내용’이 달라질 수 있는 치명적인 허점을 만들었다.
FacetCut 구조체는 트랜잭션을 실행할 facet과 실행할 동작의 타입, 함수 선택자 등만을 포함하고 있어 실제 실행 시 넘겨지는 데이터는 검증하지 못한다.
function proposeDiamondCut(Diamond.FacetCut[] calldata _facetCuts, address _initAddress) external onlyGovernor {
require(s.diamondCutStorage.proposedDiamondCutTimestamp == 0, "a3"); // proposal already exists
// NOTE: governor commits only to the `facetCuts` and `initAddress`, but not to the calldata on `initAddress` call.
// That means the governor can call `initAddress` with ANY calldata while executing the upgrade.
>> s.diamondCutStorage.proposedDiamondCutHash = keccak256(abi.encode(_facetCuts, _initAddress));
s.diamondCutStorage.proposedDiamondCutTimestamp = block.timestamp;
s.diamondCutStorage.currentProposalId += 1;
emit DiamondCutProposal(_facetCuts, _initAddress);
}
Solidity
복사
function executeDiamondCutProposal(Diamond.DiamondCutData calldata _diamondCut) external onlyGovernor {
...
>> require(approvedBySecurityCouncil || upgradeNoticePeriodPassed, "a6"); // notice period should expire
require(approvedBySecurityCouncil || !diamondStorage.isFrozen, "f3");
// should not be frozen or should have enough security council approvals
>> require(
s.diamondCutStorage.proposedDiamondCutHash ==
keccak256(abi.encode(_diamondCut.facetCuts, _diamondCut.initAddress)),
"a4"
); // proposal should be created
require(_resetProposal(), "a5"); // failed reset proposal
...
Diamond.diamondCut(_diamondCut);
emit DiamondCutProposalExecution(_diamondCut);
}
Solidity
복사
function _initializeDiamondCut(address _init, bytes memory _calldata) private {
if (_init == address(0)) {
require(_calldata.length == 0, "H"); // Non-empty calldata for zero address
} else {
// Do not check whether `_init` is a contract since later we check that it returns data.
>> (bool success, bytes memory data) = _init.delegatecall(_calldata);
require(success, "I"); // delegatecall failed
...
}
}
Solidity
복사
만약 거버너의 키가 유출되고 zkSync에서 이를 알아차렸다면, 공격자는 악의적인 _calldata가 들어있는 diamondCut이 대기 기간을 지날 때까지 기다려야 한다. 이는 zkSync에서 보안 조치를 취하기에 충분한 시간이므로 공격을 방어할 수 있을 것이다. 언뜻 보기에는 합리적인 정책처럼 보인다. 하지만 이러한 정책은 키 유출을 관리자들이 알고 있다는 가정을 먼저 하고 있다. 아래 시나리오에서는 공격을 막을 방법이 없다.
위험 시나리오
1.
합법적인 거버너가 안전한 _facetCuts와 _initAddress로 제안을 제출한다.
2.
타임락 기간이 지나 제안이 실행 가능해지는 순간, 공격자는 해당 제안을 악의적인 _calldata와 함께 실행하는 트랜잭션을 더 높은 가스비로 제출하여 프론트러닝(Front-running)한다.
3.
executeDiamondCutProposal 함수는 _calldata를 제외한 해시만 검증하므로, 이 악성 트랜잭션을 유효한 것으로 판단하고 실행한다.
결과적으로, 겉보기에는 합법적인 업그레이드였지만 실제로는 공격자가 원하는 임의의 코드가 실행되어 자금 탈취 등의 피해가 발생한다.
해당 취약점을 방어하기 위해서는 _calldata를 hash에 포함시키거나, 실행 직전이 security council에서 _calldata를 한번 더 검증해야 한다.
4. 컨트랙트 초기화
업그레이더블 컨트랙트는 constructor 대신 initialize 함수를 사용한다. 이 초기화 과정에서의 작은 실수가 공격자에게 통제권을 넘겨주는 치명적인 결과를 초래할 수 있다.
특히 구현 컨트랙트의 초기화 관리를 소홀히 하는 경우가 있다. 과거에는 구현 컨트랙트의 스토리지는 비어있거나, 설령 다른 누군가가 초기화해서 값을 채워 넣더라도 프록시의 상태와는 완전히 격리되어 있으니 괜찮다고 생각하는 경향이 있었다.
하지만 이는 구현 컨트랙트가 영원히 안전하게 그 자리에 있을 것이라는 안일한 가정에 기반한다. OpenZeppelin의 과거 UUPS 버전(v4.1~v4.3)에서 제보된 '초기화되지 않은 구현 컨트랙트를 이용한 파괴 시나리오'는 보안에 있어서 안일한 가정을 하는 것이 얼마나 위험한지를 증명했다.
[openzeppelin] 초기화되지 않은 구현체의 자기 파괴 공격
이 공격은 프록시의 소유권을 건드리지 않고, 모든 프록시가 공유하는 '로직의 원천'인 구현 컨트랙트 자체를 파괴하여 전체 시스템을 마비시킨다.
openzeppelin의 UUPS 컨트랙트는 ERC1967UpgradeUpgradeable을 상속하여 프록시 업그레이드 시 _upgradeToAndCall 또는 _upgradeToAndCallSecure을 사용한다. 후자의 경우에는 새로운 구현 컨트랙트가 업그레이드가 가능함을 확인하는 로직이 있었지만, 전자의 경우에는 그런 것이 없었다.
function _upgradeToAndCall(address newImplementation, bytes memory data, bool forceCall) internal {
_setImplementation(newImplementation);
emit Upgraded(newImplementation);
if (data.length > 0 || forceCall) {
_functionDelegateCall(newImplementation, data);
}
}
Solidity
복사
프록시에서 해당 함수를 부른다면 delegatecall을 통해 프록시의 컨텍스트에서, 구현 컨트랙트로 직접 호출한다면 구현 컨트랙트의 컨텍스트에서 실행된다. 이 점을 기억하고 공격 시나리오를 이해해보자.
프록시에서 구현 컨트랙트의 init함수를 delegatecall로 호출하여 프록시 컨텍스트에서 초기 설정이 완료된 상태. 하지만 구현 컨트랙트 자체는 초기화되지 않은 상태.
위 상황에서 프록시의 스토리지에는 적절하게 초기화 된 값들이 들어 있지만 구현 컨트랙트의 스토리지에는 owner를 비롯한 중요 변수들이 모두 기본값으로 남아있게 된다. 이를 이용하여 공격자는
1.
구현 컨트랙트로 직접 init함수를 호출하여 owner를 공격자 주소로 설정
프록시는 별도의 프록시 owner가 있고, 구현 컨트랙트의 owner는 공격자로 설정되게 된다.
2.
공격자는 다음과 같은 악의적인 구현 컨트랙트를 작성한다.
contract AttackerImpl{
function init() public{
selfdestruct(attacker address);
}
}
Solidity
복사
3.
악의적인 구현 컨트랙트를 새로운 구현 컨트랙트로 설정
현재 버전의 구현 컨트랙트로 직접 upgradeToAndCall을 호출하며 newImplementation주소로 악의적인 구현 컨트랙트의 주소를 설정하고, data에 selfdestruct가 포함된 init함수를 호출하는 내용을 담는다. 프록시가 아닌 구현 컨트랙트에서 새로운 구현 컨트랙트로 delegatecall을 수행했으므로 selfdestruct를 통해 원본 구현 컨트랙트가 삭제되게 된다.
결과적으로 프록시는 텅 빈 주소를 구현 컨트랙트로 가리키게 되어 모든 호출이 실패할 것이다.
결론적으로, "구현 컨트랙트를 초기화하지 않아도 괜찮다"는 의견은 프록시 패턴의 동작 원리를 절반만 이해한 위험한 생각이었다. 이러한 취약점들이 널리 알려진 이후, _disableInitializers()를 통해 구현 컨트랙트의 초기화를 원천적으로 차단하는 것이 업그레이더블 컨트랙트 개발의 필수적인 보안 표준으로 자리 잡게 되었다.
이 함수는 Initializer.sol에서 구현 컨트랙트의 초기화 횟수를 나타내는 _initialized의 값을 최댓값으로 설정해 더이상 컨트랙트가 초기화될 수 없도록 한다.
function _disableInitializers() internal virtual {
// ...
if ($._initialized != type(uint64).max) {
$._initialized = type(uint64).max; // 초기화 횟수를 최대값으로 설정, 재초기화 방지
emit Initialized(type(uint64).max);
}
}
Solidity
복사
constructor(){
_disableInitializers();
}
Solidity
복사
구현 컨트랙트의 생성자에서 disableInitializers를 호출하지 않은 사례다.
프록시의 상태는 올바르게 초기화했더라도 구현 컨트랙트 자체를 초기화하지 않은 채 방치하는 것은 모든 프록시를 위협하는 지점이 될 수 있다.
Biconomy의 배포 스크립트는 SmartAccount 구현 컨트랙트를 배포한 후, 별도의 초기화 과정을 거치지 않았다. 나중에 따로 init을 불러 초기화를 한다 하더라도, 배포와 초기화 사이에 간격이 있다면 잠재적인 공격자가 컨트랙트를 자기가 원하는대로 설정할 위험이 있다.
const SmartWallet = await ethers.getContractFactory("SmartAccount");
const baseImpl = await SmartWallet.deploy();
await baseImpl.deployed();
console.log("base wallet impl deployed at: ", baseImpl.address);
TypeScript
복사
위험 시나리오
1.
공격자의 구현체 소유권 탈취: 공격자는 이 주인 없는 구현 컨트랙트의 init 함수를 직접 호출하여 자신을 소유자로 등록한다.
2.
내부 함수를 이용한 delegatecall: SmartAccount에는 소유자만 호출할 수 있는 execTransaction 함수가 있다. 공격자는 소유자 권한으로 이 함수를 이용해 selfdestruct 코드가 담긴 악성 컨트랙트로 delegatecall을 실행시킬 수 있다.
3.
구현체 파괴: delegatecall은 구현 컨트랙트의 컨텍스트에서 실행되므로, selfdestruct는 구현 컨트랙트의 코드를 파괴한다.
4.
모든 프록시의 기능 마비: 이 구현체를 공유하던 모든 스마트 어카운트 프록시들은 로직을 실행할 코드가 사라졌기 때문에 영구적으로 벽돌(bricked) 상태가 된다.
만약 공격자가 초기화를 하지 않아도 컨트랙트 owner가 0으로 설정되어 있기 때문에 ercrecover가 유효하지 않은 입력값에는 0을 반환한다는 것을 이용하여 서명 검증 과정을 손쉽게 우회할 수 있다.
function execTransaction(
Transaction memory _tx,
uint256 batchId,
FeeRefund memory refundInfo,
bytes memory signatures
) public payable virtual override returns (bool success) {
...
{
...
>> checkSignatures(txHash, txHashData, signatures);
}
...
>> success = execute(_tx.to, _tx.value, _tx.data, _tx.operation, refundInfo.gasPrice == 0 ? (gasleft() - 2500) : _tx.targetTxGas);
require(success || _tx.targetTxGas != 0 || refundInfo.gasPrice != 0, "BSA013");
// ...
}
Solidity
복사
function execute(
address to,
uint256 value,
bytes memory data,
Enum.Operation operation,
uint256 txGas
) internal returns (bool success) {
if (operation == Enum.Operation.DelegateCall) {
// solhint-disable-next-line no-inline-assembly
assembly {
success := delegatecall(txGas, to, add(data, 0x20), mload(data), 0, 0)
}
} else {// ...
}
}
Solidity
복사
이러한 공격을 원천적으로 방지하기 위해, 모든 구현 컨트랙트는 생성자(constructor)에서 _disableInitializers()를 호출하여 제3자가 초기화 함수를 호출할 수 없도록 반드시 막아야 한다.
5. 프록시 패턴에 대한 근본적인 오해
때로는 복잡한 공격 벡터가 아닌, 프록시 패턴의 기본 원칙에 대한 오해만으로도 시스템의 핵심 기능이 마비될 수 있다. 다음 사례들은 사소해 보이는 설계 실수가 어떻게 업그레이드 가능성이라는 목표 자체를 무력화시키는지 보여준다.
이 사례는 업그레이더블 컨트랙트의 가장 기본 원칙인 constructor와 initializer의 역할을 혼동한 고전적인 예시다.
•
기본 원칙: 프록시의 상태(State)를 설정하는 모든 로직은 delegatecall을 통해 프록시의 컨텍스트에서 실행되어야 한다. 이를 위해 constructor가 아닌 initialize 함수를 사용한다.
그런데 ParticlePositionManager.sol 구현 컨트랙트의 constructor 내부에서, Blast 네트워크 연동에 필수적인 configure() 함수를 호출하도록 설계했다(현재는 주석처리되어 있지만 Blast에 배포할 때 제거될 예정이었다).
constructor는 구현 컨트랙트가 배포될 때 단 한 번, 구현 컨트랙트 자신의 스토리지 컨텍스트에서만 실행된다. 이는 프록시의 스토리지에는 아무런 영향을 주지 못한다. 결과적으로, 사용자와 상호작용하는 프록시는 Blast 네트워크의 수익(Yield) 및 가스비 환급 기능을 위한 필수 설정을 하지 못한 채로 남게 되어, 프로토콜이 당연히 얻어야 할 경제적 이익을 영구적으로 상실하게 된다.
// ...
constructor() payable {
_disableInitializers();
// Blast.configure();
}
// ...
Solidity
복사
ParticlePositionManager.sol
// ...
function configure() external {
IBlast(BLAST).configureClaimableYield();
IBlast(BLAST).configureClaimableGas();
IERC20Rebasing(WETHB).configure(YieldMode.CLAIMABLE);
IERC20Rebasing(USDB).configure(YieldMode.CLAIMABLE);
}
// ...
Solidity
복사
Blast.sol(library)
패치
Particle 팀은 configure함수를 프록시에서 호출하도록 변경했다.
이 사례는 UUPS 패턴의 권한 관리 체계를 완벽하게 구현한 것처럼 보이지만, 그 권한을 위임받은 주체가 정작 그 권한을 행사할 수 없어 발생하는 논리적 데드락(Logical Deadlock) 상황으로 인해 업그레이드 체인이 끊어진 상황을 보여준다.
AssetFactory.sol과 FlashSwapRouter.sol은 UUPSUpgradeable과 OwnableUpgradeable을 올바르게 상속하고, _authorizeUpgrade() 함수에 onlyOwner 제어자를 적용하여 업그레이드 권한을 소유자에게만 부여하여 신뢰할 수 없는 제3자가 컨트랙트를 업그레이드하는 것을 방지했다.
하지만 정작 두 컨트랙트의 소유자로 설정되는 컨트랙트인 moduleCore컨트랙트는 UUPS패턴의 업그레이드 과정에서 호출되는 upgradeToAndCall 함수를 구현하지 않았으며 소유자를 이전하는 기능도 가지고 있지 않았다. 결국 AssetFactory.sol과 FlashSwapRouter.sol은 업그레이드가 불가능해진다.
function initialize(address moduleCore) external initializer notDelegated {
>> __Ownable_init(moduleCore);
__UUPSUpgradeable_init();
}
Solidity
복사
function initialize(address moduleCore, address _univ2Router) external initializer notDelegated {
__Ownable_init(moduleCore);
__UUPSUpgradeable_init();
univ2Router = IUniswapV2Router02(_univ2Router);
}
Solidity
복사
패치
Cork Protocol팀은 해당 컨트랙트들을 업그레이드할 계획이 없다고 밝혔으며, 이에 소유자를 moduleCore 대신 배포자(msg.sender)로 변경하여 논리적 모순을 해결하고 현재의 구현을 유지하기로 결정했다.
마치며
이번 글에서는 실제 분석 보고서를 통해 프록시 패턴에서 발생할 수 있는 다양한 취약점 사레를 알아보았다.
프록시 컨트랙트는 불변성의 문제를 해결하는 강력한 도구이지만, 그 대가로 접근 제어, 스토리지 레이아웃, 상태 초기화라는 새로운 차원의 보안 문제를 야기한다.
이러한 업그레이더블 시스템의 보안은 단순히 개별 구성 요소의 합이 아니라, 이들의 유기적인 상호작용에 대한 전체론적 이해를 바탕으로 완성된다. 이러한 미묘하고 구조적인 취약점을 식별하고 방어하는 것은, 기존의 분석을 뛰어넘는 심층적인 전문성을 요구한다.
78리서치랩의 Web3 부서는 이러한 복합적인 상호작용을 분석하고 검증할 수 있는 전문인력을 보유하고 있습니다. 파트너의 혁신적인 기술이 검증 가능한 보안이라는 견고한 기반 위에 세워질 수 있도록, 선제적인 보안 감사를 통해 프로젝트의 미래를 보호합니다.