1. 문제

이 프록시 지갑의 admin(관리자) 이 되어서 지갑을 탈취해야 한다.

요즘 DeFi(탈중앙화 금융)에서 무언가를 하려면 수수료가 너무 비싸서 사실상 불가능하다.

그래서 친구 몇 명이 여러 트랜잭션을 하나로 묶어 처리(batch) 하면 약간이나마 비용을 줄일 수 있다는 사실을 발견했다.
이들은 이를 위해 트랜잭션을 한 번에 처리하는 스마트 컨트랙트를 개발했다.

하지만 혹시라도 코드에 버그가 있을 경우를 대비해 이 스마트 컨트랙트가 업그레이드 가능해야 했고,
또한 그들 그룹 외의 사람들은 이 컨트랙트를 사용하지 못하게 하고 싶었다.

그래서 이들은 투표를 통해 시스템에 특별한 권한을 가진 두 사람을 지정했다:
	•	Admin (관리자): 스마트 컨트랙트의 로직을 업그레이드할 수 있는 권한을 가짐
	•	Owner (소유자): 컨트랙트를 사용할 수 있는 허용된 주소 목록(화이트리스트) 을 관리할 수 있음

그렇게 컨트랙트는 배포되었고, 그룹 구성원들은 화이트리스트에 등록되었다.
모두가 악덕 채굴자들에 맞서 자신들의 성취를 자축했다.

하지만…
그들은 몰랐다.
그들의 점심값이 위험에 처해 있다는 사실을…
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
pragma experimental ABIEncoderV2;

import "./UpgradeableProxy.sol";

contract PuzzleProxy is UpgradeableProxy {
    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 {
        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 {
        _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 {
        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++) {
            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]);
            require(success, "Error while delegating call");
        }
    }
}

2. UpgradeableProxy contract 살펴보기

https://github.com/OpenZeppelin/ethernaut/blob/master/contracts/src/helpers/UpgradeableProxy-08.sol

3. Proxy Pattern

스마트 컨트랙트는 한 번 배포하면 코드를 수정할 수 없습니다. 이는 블록체인의 “불변성” 특성에 기인한 것으로, 투명성과 신뢰성 확보에 중요한 역할을 합니다. 하지만 이러한 특성은 동시에 취약점이 발견되었을 때 심각한 보안 리스크로 이어질 수 있습니다. 이미 배포된 컨트랙트에서 보안 결함이 발견되면, 그 컨트랙트는 사실상 무방비로 공격에 노출될 수밖에 없습니다.

이런 상황에 대한 대응책으로 가장 널리 사용되는 방식은 두 가지입니다.

  1. 컨트랙트에 Pausable 기능을 넣어 일시적으로 작동을 중지시키는 것
  2. 다른 하나는 완전히 새로운 버전의 컨트랙트를 배포한 뒤 기존 사용자를 새로운 컨트랙트로 마이그레이션하는 것

그러나 두 방법 모두 한계가 있습니다. Pausable은 일시적 대응에 불과하며, 새로운 컨트랙트 배포 후 마이그레이션은 사용자 자산, 상태 변수, 계약 연동 상태 등을 모두 옮겨야 하므로 비용과 복잡성이 상당히 큽니다.

이러한 문제를 해결하기 위해 등장한 것이 바로 ERC-1967 기반의 Proxy 패턴입니다.