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

@harkh017

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions