Skip to content

If Puzzle Wallet used 'call' instead of 'delegatecall' in Puzzle Wallet Ethernaut challenge, can add this one challenge as puzzle-wallet-2 #838

Description

@harkh017

**What if Puzzle Wallet used call instead of delegatecall? **

1. Quick Recap: The Original Puzzle Wallet Exploit (Real Level – delegatecall)

Core vulnerability combo:

  • Storage slot collision between Proxy (pendingAdmin / admin) and Logic (owner / maxBalance)
  • multicall uses delegatecall → preserves msg.sender and msg.value across nested calls
  • deposit() can be called multiple times via nesting → balance mapping inflated while real ETH only sent once

Classic attack flow (simplified):

  1. proposeNewAdmin(attacker) → owner = attacker (slot 0 collision)

  2. addToWhitelist(attacker)

  3. Nested multicall:

    text

    multicall([
      deposit(),
      multicall([ deposit() ])
    ]) {value: 0.001 ETH}
    

    → balances[attacker] += 0.002 ETH (two credits) → real contract ETH = 0.001 ETH

  4. execute(player, 0.001 ETH, "") → drains everything (over-withdraw)

  5. setMaxBalance(uint160(player)) → admin = player (slot collision)

Why delegatecall enables this:

  • msg.sender stays attacker in all frames → credits go to attacker
  • msg.value stays 0.001 ETH → no extra transfers, pure accounting inflation

2. Hypothetical Change: Replace delegatecall → call{value: msg.value}(...)

Now imagine the multicall loop is changed to:

solidity

(bool success, ) = address(this).call{value: msg.value}(data[i]);
require(success, "Subcall failed");

Key differences introduced:

  • Normal call → changesmsg.sender to the caller (here: the proxy itself)
  • msg.value is forwarded (your scenario), but it's a real transfer proxy → proxy (loopback)

3. Exploit Attempt #1 – Direct nested deposits (like original)

What happens:

  • Outer multicall called by attacker → msg.sender = attacker
  • First sub-call: call(deposit) → sub-frame: msg.sender = proxy → balances[proxy] += msg.value (not attacker's slot!)
  • Inner multicall: same → again msg.sender = proxy in sub-deposit → all credits go to balances[address(this)] (proxy's own entry)

Result:

  • Attacker balance unchanged (0)
  • Proxy's own balance inflated (useless for withdrawal)
  • Real ETH net unchanged (loopback cancels)
  • Cannot call execute directly → reverts on balances[msg.sender] < value

Verdict: Classic nested deposit inflation fails — no over-credit to attacker.

4. Exploit Attempt #2 – Inflate proxy's balance entry, then wrap execute

Step 1: Inflate balances[proxy] via nested multicall + deposits

  • Send tiny value (e.g. 1 wei) in multicall tx
  • Deep nesting → balances[proxy] becomes very large (e.g. 10 ETH fake)

Step 2: Separate tx – multicall([ execute(player, realBalance, "") ])

  • Top call: attacker → proxy.multicall → msg.sender = attacker
  • Sub-call: proxy.call(execute calldata) → execute frame: msg.sender = proxy
  • Inside execute:
    • require(balances[msg.sender] >= value) → balances[proxy] >= value → passes if inflated!
    • to.call{value: value}("") → sends real ETH to player

Does this drain the contract?

Yes — it works (with limits):

  • execute check uses proxy's inflated accounting
  • Real send uses contract.balance (which is not inflated — only original funds + tiny sent)
  • You can drain all real ETH in the contract (initial level funds + your tiny deposit)
  • But you cannot drain more than exists (no magical ETH creation)

5. Visual Representation Ideas (for your post/portfolio)

Diagram 1 – Original delegatecall flow (use draw.io / excalidraw)

text

Attacker ──► Proxy.multicall ── delegatecall ── deposit() ── balances[attacker] += v
                        │
                        └─ delegatecall ── multicall ── delegatecall ── deposit() ── balances[attacker] += v

Labels: "msg.sender = attacker everywhere", "msg.value preserved", "No real transfer"

Diagram 2 – Hypothetical call flow (nested deposit)

text

Attacker ──► Proxy.multicall ── call{value:v} ── deposit() ── balances[proxy] += v
                        │                  (msg.sender = proxy)
                        └─ call{value:v} ── multicall ── call{value:v} ── deposit() ── balances[proxy] += v

Labels: "msg.sender flips to proxy", "Loopback ETH transfer (net 0)"

Diagram 3 – Wrapped execute drain

text

Attacker ──► Proxy.multicall ── call{value:0} ── execute(player, realBalance, "")
                                     (msg.sender = proxy inside execute)
                                     ↓
                               balances[proxy] checked → passes
                               ↓
                               real ETH sent to player

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions