Skip to content

Latest commit

 

History

History
24 lines (18 loc) · 5.52 KB

Solution.md

File metadata and controls

24 lines (18 loc) · 5.52 KB

Puzzle Wallet

The goal of this level is to hijack the wallet and become admin of the proxy. Upgradeable contracts are used so developers can update their contract's code as everything on Ethereum is immutable and cannot be modified by default. Even though blockchain-based software benefits from immutability, there are still times where mutability is needed for things such as bug fixes or potential product improvements. Upgradeable contracts require two contracts - a proxy and an implementation. The proxy is the storage layer whereas the implementation is the logic layer. The user inteacts with the logic layer via the proxy, and when an update to the logic layer is required the logic contract's address is updated in the proxy contract so users can interact with the new logic contract. Thus, the proxy and logic contracts are still immutable in the sense that their code cannot be changed, however, the logic contract can be sreplaced by another contract. The key to solving this level is understanding upgrade patterns in Ethereum, especially OpenZeppelin's UpgradeableProxy, with respect to storage mapping.

The issue with PuzzleWallet is that the proxy pattern used is vulnerable to a storage slot collision exploit, similar to the attack used to solve Preservation. Solidity automatically sequences storage slots when a contract inherits another contract such that there are no collisions between the variables of the parent and child contracts. With proxy contracts, however, the compiler has no way of knowing that we want to execute our logic on another contract so storage slots are not sequenced automatically. With PuzzleWallet and PuzzleProxy, there are variable collisions: pendingAdmin and owner in slot 0, and admin and maxBalance in slot 1, respectively. Thus, if we update maxBalance then we are able to overwrite admin.

We could call setMaxBalance() to set a new balance, however, it has an onlyWhitelisted, which requires us being whitelisted by the owner. There is also a require statement in the setMaxBalance() function that ensures the contract's balance is equal to 0. With the variable collisions, if we're able to update pendingAdmin then wecould overwrite owner and whitelist ourselves. We can update pendingAdmin by calling proposeNewAdmin, which anyone can call. When we pass in a _newAdmin to proposeNewAdmin we change the owner in the implementation contract. Once we are owner, we can call addToWhiteList adding ourselves, and then we pass the modifier for setMaxBalance().

After doing so, we need to withdraw all the Ether in the contract to pass the require(address(this).balance == 0, "Contract balance is not 0"); statement. We can check the contract's balance by copying the instance address and pasting it on Etherscan, or by typing into the console: await getBalance(instance). The contract has a balance of 0.001 ETH. We can see that execute() would allow us to withdraw the Ether. The only condition we need to be cognizant of now is the require as we are already whitelisted: require(balances[msg.sender] >= value, "Insufficient balance");. This means that we'd need to send at least 0.001 Ether to the contract, so our balance is greater than or equal to 0.001 Ether, which would allow us to withdraw the 0.001 ETH. To update our balance we need to use the deposit() function. But, doing so would still leave the original 0.001 Ether in the contract. So, we want to find a way to double our 0.001 deposit so we can withdraw the 0.001 ETH that the contract currently holds.

multicall() is an interesting function as it takes in an array of bytes as data, allowing us to call a function multiple times in a single transaction. Within multicall() there is a bool depositCalled which is initialized as false. This variable is used to prevent deposit() from being called multiple times, however, the logic itself is faulty:

assembly {
    selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
    require(!depositCalled, "Deposit can only be called once");
    // Protect against reusing msg.value
    depositCalled = true;
}

multicall() takes the function signature of each call and checks if the selector is equal to deposit(), and sets depositCalled equal to true so another deposit() call cannot be made in the same multicall() transaction. However, multicall() does not check if multicall() is passed into itself. Since multicall() executes arbitrary code we can pass in a multicall() call as a wrapper around the deposit() function to bypass this check, allowing us to call deposit() multiple times. This will allow us to increase our balance more than what we've actually contributed. With that, we can pass multicall(deposit()) calls into multicall() with a value of 0.001 ETH. We do this by using a bytes[] with two slots of data encoded using the abi.encodeWithSelector with one call directly to deposit() and the other multicall(deposit()). Then we can call execute() for the full 0.002 ETH. With the contract balance equal to 0, we can now call setMaxBalance(), passing in our address as a uint256 to set ourselves as the admin.

To pass this level, deploy AttackPuzzleWallet from AttackPuzzleWallet.sol with some Ether (it doesn't matter what you pass in, so long as it is >= 0.001 ETH, as the contract self-destructs sending the Ether back to you at the end) and the wallet address that we obtained earlier as the wallet address in the constructor().