[Hacking Series] #02 Unsafe delegateCall

Seungmin Jeon
Decipher Media |디사이퍼 미디어
20 min readJan 13, 2023
Smart Contract Hacking [source]

본 게시글은 이더리움 스마트 컨트랙트 해킹 유형을 분석한 시리즈 중 2편입니다. 이번 편에서는 2017년 Parity Wallet의 해킹 사태를 통해 delegateCall 함수와 이를 이용한 해킹 방식에 대해 분석합니다.

Author : Seungmin Jeon
Reviewer : Yohan Lim

서울대학교 블록체인 학회 디사이퍼에서 스마트 컨트랙트 해킹에 대한 글을 시리즈로 연재합니다. 본 글은 해킹 시리즈의 2편으로, 다른 편을 읽고 싶으시다면 아래의 리스트를 확인해주십시오.

[Hacking Series]

1편: Intro
2편: Unsafe Delegatecall
3편: Front Running
4편: Signature Replay
5편: Denial of Service
6편: Arithmetic Overflow / Underflow & Conclusion

목차

  1. Intro
  2. What is delegateCall?
  3. Parity Wallet Hacking
  4. Solution
  5. 결론

Intro

폴카닷 생태계의 핵심 개발사, Parity [source]

2017년 7월, Parity가 만든 이더리움 다중서명(Multisig) 지갑 Parity Wallet에서 약 150,000 ETH가 해킹되는 사건이 발생하였습니다. 피해 금액은 당시 가격으로 약 3천만 달러, 현재 ETH의 가격으로 환산하면 약 1억 8천만 달러에 해당하는 엄청난 자금이었습니다. 이는 이후 분석을 통해, 스마트 컨트랙트 상의 ‘Unsafe DelegateCall’으로 인한 문제점이었다는 것이 밝혀졌습니다. 해커는 어떠한 원리로 자금을 탈취할 수 있었던 것이고, 이를 어떻게 방지할 수 있을까요?

What is delegateCall?

  • 기본 개념

먼저 사건의 발단인 delegateCall의 원리에 대해 이야기해보겠습니다. 솔리디티에는 delegateCall이라는 아주 유용한 함수가 존재합니다. 이해를 돕기 위해, 먼저 call의 개념을 짚고 넘어가겠습니다.

컨트랙트 동작에 필요한 모든 함수를 한 컨트랙트 내에서 정의할 수는 없습니다. 때로는 다른 컨트랙트 내의 함수를 내 컨트랙트에서 작동시켜야 하는 일들도 존재합니다. 이를 위해, call이라는 함수가 존재합니다. 이는 컨트랙트 외부에 있는 다른 컨트랙트 내의 함수를 데이터, 가스, Eth와 함께 호출하는, 유용한 기능입니다. 예를 들어, Alice가 컨트랙트 A를 통해 컨트랙트 B에 있는 함수 ValueChange()을 call하였다고 해보겠습니다. 여기서 ValueChange라는 함수는 변수 a의 값을 1에서 2로 바꾸는 함수라고 해봅시다. 그러면, 다음 그림과 같은 일이 발생합니다.

Call

여기서 중요한 점은 두 가지입니다. 첫 번째, Alice가 컨트랙트 A에서 호출한 ValueChange 함수는 A의 스토리지가 아니라 빨간색으로 표기된 B의 스토리지 정보를 변경시킵니다. 즉, 그림처럼 B의 스토리지에 있는 변수 a의 값이 바뀌게 됩니다. 두 번째, Alice가 call 함수를 실행하였다고 하더라도, 컨트랙트 B 입장에서 ValueChange 함수를 호출하는 주체는 컨트랙트 A로 보이기 때문에 msg.sender (호출자)는 컨트랙트 A입니다.

여기서 Alice는 이런 생각을 할 수 있습니다.

다른 컨트랙트 내 함수를 불러와서, 내 컨트랙트의 스토리지 정보를 변경할 순 없을까?

여기서 delegateCall 을 사용할 수 있습니다. delegateCall 은 call과 유사하지만, 조금 다른 방식의 로직을 따릅니다. delegateCall은 다른 컨트랙트의 함수를 내 컨트랙트 내로 가져와서 실행하는 EVM 함수입니다. 이번에는 Bob이 컨트랙트 B 내 example 함수를 컨트랙트 A에서 delegateCall 하였다고 한다면, 다음 그림과 같은 일이 발생합니다.

DelegateCall

call의 경우와 다르게, delegateCal은 호출한 함수를 내 컨트랙트 내의 스토리지로 가지고 와서 처리합니다. 즉, B의 ValueChange함수의 코드를 A에서 실행하는 것이지요. 이에 따라 변수 a도 컨트랙트 A의 스토리지에서 변경되게 됩니다. 또한, msg.sender도 바뀌지 않고 Bob의 주소로 설정됩니다.

  • delegateCall의 활용

delegateCall은 어떤 상황에서 유용할까요? 여러 상황에서 쓰일 수 있지만, 가장 대표적인 사용 사례는 스마트 컨트랙트를 업그레이드 할 때입니다.

이더리움 블록체인에 스마트 컨트랙트를 배포하면, 더 이상 수정할 수 없습니다. 그렇기 때문에 만약 어떤 컨트랙트에 문제가 생기거나, 업그레이드해야 하는 로직이 발견된다면 새로운 컨트랙트를 배포해야 합니다. 그런데 여기에는 두 가지 문제점이 있습니다.

  1. 컨트랙트를 새로 배포하면, 기존에 컨트랙트를 사용하던 유저들의 데이터(상태)는 초기화됩니다. 그렇다고 이 정보 모두를 새 컨트랙트에 복사하는 것은 가스비가 너무 많이 소요되는, 비효율적인 작업입니다.
  2. 컨트랙트의 주소가 바뀌어서, 재배포할 컨트랙트와 상호작용하는 다른 컨트랙트들 및 프론트엔드 내 요소들까지 모두 업데이트해야 합니다.

따라서, 고칠 사항이 있다고 막무가내로 컨트랙트를 재배포하는 것은 너무나도 비용이 많이 드는 솔루션입니다.

delegateCall을 사용한다면 이 문제를 해결할 수 있습니다. 다음과 같은 컨트랙트 프레임워크를 구성했다고 생각해봅시다.

여기서 두 컨트랙트의 역할은 각각 다음과 같습니다. B는 모든 컨트랙트 로직 및 함수들을 담고 있고, A는 delegateCall로 B의 함수들을 실행하는 역할을 합니다. 그렇게 되면 delegateCall의 특성 때문에 A의 스토리지에 함수 실행과 관련된 정보가 담기면서, 데이터를 모두 A에 담을 수 있게 됩니다. 즉, A에는 정보가, B에는 로직이 들어있는 방식이죠.

그런 다음 만약 로직에 변경점이 생긴다면, 위와 같이 새로운 스마트 컨트랙트 B’을 배포한 다음 이를 A와 연결해주기만 하면 됩니다. 그러면 유저의 데이터는 모두 A에 담겨 있고, 상호작용하는 다른 컨트랙트들도 A와 연결되어 있기 때문에, 매우 쉽게 스마트 컨트랙트를 업데이트할 수 있습니다.

이런 방식으로 전체 코드의 논리 구조를 추상화하는 기법을 upgradable smart contract framework, 혹은 Proxy 패턴이라고 부릅니다. 이는 코드 재사용성을 높이고 코드 배포 비용(가스비)을 줄이는 데에 기여하기 때문에, 많은 프로젝트들에서 사용되고 있습니다.

그런데, 안전하지 않은 delegateCall 패턴을 사용하면, 코드 내에 취약점이 생길 수 있습니다.

Parity Wallet Hacking

Intro에서 말했던 Parity Wallet 해킹 사례는 안전하지 않은 delegateCall을 사용해서 발생하였습니다. 이에 대해 자세히 알아보도록 합시다.

Parity Wallet은 단일 서명을 통해 지갑에서 암호화폐를 출금하던 방식과 달리, 다중 서명을 통해 출금이 가능하도록 한 스마트 컨트랙트 지갑(Contract Wallet)입니다. 이는 Proxy 패턴과 유사한 구조로 설계되었고, WalletLibrary와 Wallet이라는 컨트랙트를 통해 로직을 구성하였습니다. 컨트랙트 전문은 링크에서 보실 수 있습니다.

Wallet 컨트랙트는 초기 설정 작업을 포함한 모든 로직을 WalletLibrary로의 delegateCall을 통해 수행하도록 설계되었습니다. 이에 따라, 초기에 지갑 소유자(m_owners)를 설정하는 작업 또한 constructor가 아니라 WalletLibrary로의 delegateCall을 통해 수행됩니다. 해당 작업을 수행하는 코드는 아래와 같습니다.

// WALLET CONSTRUCTOR
// calls the `initWallet` method of the Library in this context
function Wallet(address[] _owners, uint _required, uint _daylimit) {
// Signature of the Wallet Library's init function
bytes4 sig = bytes4(sha3("initWallet(address[],uint256,uint256)"));
address target = _walletLibrary;
// Compute the size of the call data : arrays has 2
// 32bytes for offset and length, plus 32bytes per element ;
// plus 2 32bytes for each uint
uint argarraysize = (2 + _owners.length);
uint argsize = (2 + argarraysize) * 32;
assembly {
// Add the signature first to memory
mstore(0x0, sig)
// Add the call data, which is at the end of the
// code
codecopy(0x4, sub(codesize, argsize), argsize)
// Delegate call to the library
delegatecall(sub(gas, 10000), target, 0x0, add(argsize, 0x4), 0x0, 0x0)
}
}

위 코드에서 볼 수 있듯이, 초기 설정 작업은 WalletLibrary 내 initWallet()이라는 함수를 통해 딱 한 번만 진행됩니다. 해당 함수는 다음과 같은 구조로 되어 있습니다.

// constructor - just pass on the owner array to the multiowned and  // the limit to daylimit  
function initWallet(address[] _owners, uint _required, uint _daylimit) {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}

function initDaylimit(uint _limit) {
m_dailyLimit = _limit;
m_lastDay = today();
}

function initMultiowned(address[] _owners, uint _required) {
m_numOwners = _owners.length + 1;
m_owners[1] = uint(msg.sender);
m_ownerIndex[uint(msg.sender)] = 1;
for (uint i = 0; i < _owners.length; ++i)
{
m_owners[2 + i] = uint(_owners[i]);
m_ownerIndex[uint(_owners[i])] = 2 + i;
}
m_required = _required;
}

initWallet 이 호출되면, initDayLimit을 통해 하루 최대 인출 금액(m_dailyLimit)을 입력받은 _limit으로 설정하고, initMultiowned 함수를 통해 소유자(m_owners)를 입력받은 주소 목록과 msg.sender(함수 호출자)로 설정합니다.

원래대로라면 initWallet은 최초 지갑 설정 시 딱 한 번만 호출됩니다. 그러나 공격자는 이 함수를 통해 지갑들의 자금에 접근할 수 있게 되었고, 인출 금액도 마음대로 늘릴 수 있었습니다. 그 과정을 자세히 알아보도록 하겠습니다.해당 함수를 호출하기 위해, 공격자는 Wallet 컨트랙트에 있는 fallback 함수를 호출하였습니다.

fallback 함수는 컨트랙트 내 존재하지 않는 함수가 호출되었을 때 실행되는 함수로, Wallet 컨트랙트에서는 다음과 같은 형태를 가지고 있었습니다.

function() payable {
// just being sent some cash?
if (msg.value > 0)
Deposit(msg.sender, msg.value);
else if (msg.data.length > 0)
_walletLibrary.delegatecall(msg.data);
}

여기서 공격자는 msg.value(트랜잭션을 통해 전달하는 ETH의 양)를 0으로 설정하고, msg.data를 포함시켜 맨 아랫줄에 있는_walletLibrary.delegatecall(msg.data) 를 실행시킵니다. 여기서 msg.data는 완전한 calldata의 형태로, 트랜잭션을 구성하는 사람이 마음대로 넣을 수 있는 데이터입니다. 공격자는 여기에 initWallet() 함수의 calldata를 넣어 initWallet()delegateCall합니다. 그러면 위에서 서술한 delegateCall의 특성 때문에 공격자의 주소가 msg.sender가 되고, initMultiowned()함수를 통해 해당 주소가 지갑의 소유자 목록(m_owners)에 들어가게 됩니다. 또한, 공격자가 입력값으로 넣은 _limit 으로 하루 최대 인출 금액이 설정되었습니다.

그런 다음, 공격자는 똑같은 방식으로 execute() 함수delegateCall하였습니다. 이 함수는 하루 최대 인출 금액 내의 자금을 다중 서명 수합 프로세스 없이 즉시 인출하는 역할을 수행합니다.

function execute(address _to, uint _value, bytes _data) external onlyowner returns (bytes32 o_hash) {
// first, take the opportunity to check that we're under the daily limit.
if ((_data.length == 0 && underLimit(_value)) || m_required == 1) {
// yes - just execute the call.
address created;
if (_to == 0) {
created = create(_value, _data);
} else {
if (!_to.call.value(_value)(_data))
throw;
}
SingleTransact(msg.sender, _value, _to, _data, created);
} else {
// determine our operation hash.
o_hash = sha3(msg.data, block.number);
// store if it's new
if (m_txs[o_hash].to == 0 && m_txs[o_hash].value == 0 && m_txs[o_hash].data.length == 0) {
m_txs[o_hash].to = _to;
m_txs[o_hash].value = _value;
m_txs[o_hash].data = _data;
}
if (!confirm(o_hash)) {
ConfirmationNeeded(o_hash, msg.sender, _value, _to, _data);
}
}
}

앞서 공격자는 initWallet() 을 통해 하루 최대 인출 금액을 늘려놓았던 상태이므로, execute() 함수를 통해 해당 금액만큼의 자금을 인출할 수 있었습니다.

원래 이 함수는 소유자만 실행할 수 있도록 onlyOwner 라는 modifier를 가지고 있었지만, 이미 공격자의 주소가 소유자 목록에 포함되었기 때문에 자금 인출을 막을 수 없었습니다.

이 공격의 개요를 그림으로 나타내면 다음과 같습니다.

정리하자면 공격자는

  1. 지갑 설정 시 한 번만 호출되어야 하는 initWallet()함수를 fallback 함수를 통해 우회하여 delegateCall한 다음, 소유자 목록에 본인의 주소를 추가하고, 하루 최대 인출 금액을 82,189 Eth로 증가시켰습니다. (트랜잭션 링크)
  2. 그런 다음 또 한번 fallback 함수를 통해 execute()함수를 delegateCall하여 하루 최대 인출 금액 한도 내의 금액을 본인의 주소로 인출하였습니다. (트랜잭션 링크)

Solution

  • Parity의 대응

Parity는 결국 이러한 공격으로 약 150,000 Eth에 해당하는 자금을 탈취당했습니다. 추가 피해를 방지하기 위해, Parity는 다음과 같은 modifier를 추가해 initWallet() 함수를 보호하려고 하였습니다.

modifier only_uninitialized { if (m_numOwners > 0) throw; _; }

이에 따라, 만약 공격자가 Wallet 컨트랙트에 있는 delegateCall 메커니즘을 통해 initWallet() 을 호출하면, 해당 modifier에 의해 트랜잭션이 거부되도록 하였습니다.

그러나 약 네 달 후, Parity Wallet은 또 한 번의 공격을 당하게 됩니다. 해당 공격은 WalletLibrary 컨트랙트를 파기(selfdestruct)하는 방식으로 이루어졌고, 모든 Parity 지갑들이 동결당하는 사태가 발생하였습니다. 이는 delegateCall과는 관련 없는 내용이기에, 깊게 다루지는 않겠습니다. 관심있으신 독자분들은 관련 Parity의 자체 리포트Openzeppelin의 분석을 참조하시기 바랍니다.

  • Uniswap V3의 NodelegateCall

그러나 위 Parity의 modifier는 delegateCall을 통한 공격 자체를 막는 데에는 훌륭한 역할을 할 수 있습니다. 현재 Uniswap V3도 이러한 형태의 modifier를 가지고, delegateCall을 통한 공격을 방지하고 있습니다. 아래는 Uniswap V3에서 사용하는 delegateCall 방지용 컨트랙트입니다.

pragma solidity =0.7.6;

/// @title Prevents delegatecall to a contract
/// @notice Base contract that provides a modifier for preventing delegatecall to methods in a child contract
abstract contract NoDelegateCall {
/// @dev The original address of this contract
address private immutable original;

constructor() {
// Immutables are computed in the init code of the contract, and then inlined into the deployed bytecode.
// In other words, this variable won't change when it's checked at runtime.
original = address(this);
}

/// @dev Private method is used instead of inlining into modifier because modifiers are copied into each method,
/// and the use of immutable means the address bytes are copied in every place the modifier is used.
function checkNotDelegateCall() private view {
require(address(this) == original);
}

/// @notice Prevents delegatecall into the modified method
modifier noDelegateCall() {
checkNotDelegateCall();
_;
}

위 컨트랙트에서 Uniswap은 컨트랙트 자체의 주소를 original 에 저장한 뒤, 함수를 호출한 대상이 original 이 아니라면 트랜잭션 실행을 중지하는 메커니즘을 구현하였습니다. 즉 이 컨트랙트를 상속받은 뒤, 함수에 noDelegateCall modifier를 추가하면 delegateCall을 방지할 수 있는 것입니다.

  • 검증된 Proxy 패턴 사용

Parity Wallet 해킹은 결국 검증되지 않은 Proxy 패턴을 사용하였기에 발생한 문제였습니다. 이에 대해 스마트 컨트랙트 오픈 소스 플랫폼인 Openzeppelin 측에서는 다음과 같은 이야기를 하였습니다.

공유된 라이브러리로 논리 구조를 추상화하는 기법은 꽤 유용하다는 것을 기억해두어야 한다. 이는 코드 재사용성을 높이고 배포 비용(가스비)을 줄이는 데 기여한다. 그러나 이번 공격은 이더리움 생태계 내에서 이러한 코딩 패턴이 효과적이고 안전하게 실행될 수 있도록 보장하는 일련의 실험과 표준이 필요하다는 것을 명백히 보여주는 사례이다. 그렇지 않으면, 사소해 보이는 버그도 파멸적인 결과를 불러올 수 있다.

Openzeppelin, 2017.

이후 Openzeppelin에서는 Proxy 패턴 표준을 구현하여 배포하였고, 이를 플러그인 형태로 Hardhat이나 Truffle에서 사용할 수 있도록 하였습니다.

또한 EIP-1822: Universal Upgradeable Proxy Standard(UUPS)를 통해 Proxy 패턴이 EIP 형태로 표준화되기도 하였습니다. 위 두 가지 방식은 현재 Proxy 패턴 구현에 있어 가장 많이 사용되고 있습니다.

결론

delegateCall 은 Proxy 패턴 구축을 통해 스마트 컨트랙트를 손쉽게 업그레이드할 수 있는 강력한 도구로써 사용되는 반면, 잘못 사용되었을 때에는
Parity Wallet의 사례처럼 사용자에게 큰 피해를 입힐 수 있게 되기에 설계 시 깊은 주의가 필요합니다.

한편 Parity Wallet 해킹 사태는 이더리움 스마트 컨트랙트 생태계 내에서 ‘표준’에 대한 고민이 부족했던 것으로부터 기인하였습니다. 비단 delegateCall 뿐만 아니라, 스마트 컨트랙트 내 다른 영역에서도 보안에 대한 표준이 잘못 구현되거나 그에 대한 논의가 부족하다면, 언제든지 이러한 사고는 재발할 수 있습니다. 앞으로 안전한 컨트랙트 구현에 대한 논의가 더욱 많이 이루어지고 관련 표준들이 정립되어서, 더욱 탄탄한 보안을 갖춘 블록체인 생태계가 구축되길 바랍니다.

Reference

--

--