✅Puzzle Wallet
proxy contract
Description
Nowadays, paying for DeFi operations is impossible, fact.
A group of friends discovered how to slightly decrease the cost of performing multiple transactions by batching them in one transaction, so they developed a smart contract for doing this.
They needed this contract to be upgradeable in case the code contained a bug, and they also wanted to prevent people from outside the group from using it. To do so, they voted and assigned two people with special roles in the system: The admin, which has the power of updating the logic of the smart contract. The owner, which controls the whitelist of addresses allowed to use the contract. The contracts were deployed, and the group was whitelisted. Everyone cheered for their accomplishments against evil miners.
Little did they know, their lunch money was at risk…
You'll need to hijack this wallet to become the admin of the proxy.
Things that might help:
Understanding how
delegatecall
s work and howmsg.sender
andmsg.value
behaves when performing one.Knowing about proxy patterns and the way they handle storage variables.
Background Knowledge
Solidity Patterns - Proxy Delegate:
https://fravoll.github.io/solidity-patterns/proxy_delegate.html
WTF Academy tutorials (in Chinese):
https://wtf.academy/solidity-application/ProxyContract/
https://wtf.academy/solidity-application/Upgrade/
Code Audit
The vulnerability is clear: the storage layouts of PuzzleProxy
and PuzzleWallet
are different. Storage layout of PuzzleProxy
:
Storage layout of PuzzleWallet
:
The objective is to overwrite PuzzleProxy
storage slot 1 with our Metamask wallet address. Equivalently, we can overwrite PuzzleWalelt
storage slot 1 uint256 public maxBalance
. This can be done by calling PuzzleWallet::setMaxBalance()
:
In order to call this function, we have to satisfy two requirements:
onlyWhitelisted
: Caller address is on the whitelist.require(address(this).balance == 0)
: The balance ofPuzzleWallet
contract is completely drained.
Goal 1: Get onto the whitelist
PuzzleWallet::addToWhitelist()
handles whitelist access control:
We have to be the owner to use this function. Note that address public owner
is at PuzzleWallet
storage slot 1. It corresponds to PuzzleProxy
storage slot 1, which is address public pendingAdmin
. We do have control of pendingAdmin
by calling the PuzzleProxy::proposeNewAdmin()
:
So for solving goal 1, we call PuzzleProxy::proposeNewAdmin()
and then PuzzleWallet::addToWhitelist()
.
Goal 2: Completely drain the balance
First, let's enumerate the balance of the current owner:
This is equivalent to 0.001 ether. Our objective is to drain this balance.
The only way to transfer balance out is PuzzleWallet::execute()
:
As msg.sender
, we have to own all the balance in this contract. PuzzleWallet::deposit()
handles this:
The downside of this function is inconvenience. We have to send msg.value
many many times to reach the goal. Luckily, there is another function PuzzleWallet::multicall()
that batches multiple function calls into one:
The bad news is that require(!depositCalled, "Deposit can only be called once")
prevents us from calling PuzzleWallet::deposit()
multiple times. However, there is a sneaky matryoshka doll way to bypass this check.
Basically for each call to PuzzleWallet::multicall()
, we can only call PuzzleWallet::deposit()
once. But, we can call multicall()
inside multicall()
. In that new multicall()
, we can call deposit()
. Pictorially:
If we send msg.value == 0.001 ether
, the contract would have 0.002 ether (0.001 ether was deposited during construction) and balance[player] == 0.002
because of the nested multicall()
. At this stage we can drain that 0.002 ether balance by calling execute()
.
The difficult part is writing the nested calling data. The code snippet below is taken from https://blog.dixitaditya.com/ethernaut-level-24-puzzle-wallet:
So for solving goal 2, we first call nested multicall()
to increase our balance, then call execute()
to drain the balance.
The very last step of our exp is calling sexMaxBalance()
to change the owner.
Solution
Step 1: In Remix, interact with IPuzzleProxy
via "At Address". Note that this address is just await contract.address
. Call proposeNewAdmin(<your_Metamask_wallet_address>)
. In Chrome console, verify owner
is overwritten: await contract.owner()
.
Step 2: In Chrome console, do await contract.addToWhitelist('<your_Metamask_wallet_address>')
. Now we get onto the whitelist.
Step 3: Nested multicall:
I can't get this to work in Remix, so just do this in Chrome console. The syntax is not important.
Step 4: Do await contract.execute(player, toWei('0.002'), 0x0)
. This drains the balance in PuzzleWallet
.
Step 5: Do await contract.setMaxBalance(player)
. At this stage, the owner of PuzzleProxy
is overwritten.
Summary
Next time, those friends will request an audit before depositing any money on a contract. Congrats!
Frequently, using proxy contracts is highly recommended to bring upgradeability features and reduce the deployment's gas cost. However, developers must be careful not to introduce storage collisions, as seen in this level.
Furthermore, iterating over operations that consume ETH can lead to issues if it is not handled correctly. Even if ETH is spent, msg.value
will remain the same, so the developer must manually keep track of the actual remaining amount on each iteration. This can also lead to issues when using a multi-call pattern, as performing multiple delegatecall
s to a function that looks safe on its own could lead to unwanted transfers of ETH, as delegatecall
s keep the original msg.value
sent to the contract.
Move on to the next level when you're ready!
Further Reading
Upgradeable contracts can go wrong. Smart Contract Programmer explained some unsafe code:
Last updated