Puzzle Wallet

Become the admin of the Puzzle Wallet contract by exploiting delegatecall and storage layout vulnerabilities.

Vulnerable Code
Analyze the Solidity code below to find the vulnerability.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; // OwnableUpgradeable? Assume standard Ownable for now contract PuzzleProxy /* is Ownable ??? */ { // Proxy Contract address public pendingAdmin; address public admin; address public implementation; // Address of PuzzleWallet logic contract constructor(address _admin, address _implementation, bytes memory _initData) payable { admin = _admin; implementation = _implementation; (bool success,) = _implementation.delegatecall(_initData); require(success, "Proxy init failed"); } // Function to propose a new admin function proposeNewAdmin(address _newAdmin) public { // In real proxy, admin check is complex. Assume basic check for now require(msg.sender == admin, "Not admin"); pendingAdmin = _newAdmin; } // Function for pendingAdmin to approve the upgrade (not directly relevant to exploit) function approveNewAdmin(address _expectedAdmin) public { require(msg.sender == pendingAdmin, "Not pending admin"); require(msg.sender == _expectedAdmin, "Not expected admin"); admin = pendingAdmin; pendingAdmin = address(0); } // Function to upgrade implementation (not directly relevant to exploit) function upgradeTo(address _newImplementation) public { require(msg.sender == admin, "Not admin"); implementation = _newImplementation; } // Fallback function delegates calls to implementation fallback() external payable { address _implementation = implementation; assembly { calldatacopy(0, 0, calldatasize()) let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } } contract PuzzleWallet is Ownable { // Logic Contract (Ownable state likely separate) address public owner; // Slot 0 (Ownable's owner) uint256 public maxBalance; // Slot 1 mapping(address => bool) public whitelisted; // Slot 2 (Mapping base slot) mapping(address => uint256) public balances; // Slot 3 (Mapping base slot) function init(uint256 _maxBalance) public { // Initializer - sets maxBalance, SHOULD set owner too require(maxBalance == 0, "Already initialized"); // Basic re-init protection maxBalance = _maxBalance; owner = msg.sender; // <-- PROBLEM: Standard Ownable constructor sets owner, initializer might clash } // Function to set max balance - intended for owner function setMaxBalance(uint256 _maxBalance) external { // Assume Ownable's onlyOwner modifier is used implicitly require(msg.sender == owner, "Not owner"); // Explicit check if Ownable not quite right maxBalance = _maxBalance; } // Function to add to whitelist function addToWhitelist(address addr) public { require(msg.sender == owner, "Not owner"); whitelisted[addr] = true; } // Deposit funds - only whitelisted function deposit() external payable { require(whitelisted[msg.sender], "Not whitelisted"); // Vulnerable: check against maxBalance AFTER increasing balance balances[msg.sender] += msg.value; // Potential reentrancy, but not the main exploit path require(address(this).balance <= maxBalance, "Max balance reached"); } // Execute arbitrary call - only whitelisted function execute(address to, uint256 value, bytes calldata data) external payable { require(whitelisted[msg.sender], "Not whitelisted"); balances[msg.sender] -= value; // Decrement user balance first (bool success, ) = to.call{value: value}(data); require(success, "Execution failed"); } // Multicall function - processes multiple calls in one tx // Vulnerable: Allows calling deposit() multiple times within one transaction function multicall(bytes[] calldata data) external payable { // Delegatecall vulnerability is NOT here; multicall allows repeated calls // require(whitelisted[msg.sender]); // Should check whitelist? Assume yes. bool depositCalled = false; for (uint i = 0; i < data.length; i++) { // Check if the call is to the deposit function bytes4 selector = bytes4(data[i][:4]); if (selector == this.deposit.selector) { require(!depositCalled, "Deposit can only be called once"); depositCalled = true; } (bool success, ) = address(this).call{value: msg.value}(data[i]); // Pass value only once? Or per call? Ambiguous. Assume value applies to the whole multicall? Ethernaut sends value with multicall. require(success); } } }
Submit Explanation
Explain the vulnerability and how to exploit it.
Hints (5)
Just a little peak
Hint 1
Hint 2
Hint 3
Hint 4
Hint 5
Explanation
Discomfort = Learning