-
Notifications
You must be signed in to change notification settings - Fork 779
Description
**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):
-
proposeNewAdmin(attacker) → owner = attacker (slot 0 collision)
-
addToWhitelist(attacker)
-
Nested multicall:
text
multicall([ deposit(), multicall([ deposit() ]) ]) {value: 0.001 ETH}→ balances[attacker] += 0.002 ETH (two credits) → real contract ETH = 0.001 ETH
-
execute(player, 0.001 ETH, "") → drains everything (over-withdraw)
-
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