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