Skip to content

Latest commit

Β 

History

History
132 lines (90 loc) Β· 6.05 KB

File metadata and controls

132 lines (90 loc) Β· 6.05 KB

ETH Double-Spend & Refund Exploit via BoringBatchable in MISO Auction

Summary

The MISO Dutch Auction inherited BoringBatchable, which allows batching of multiple calls via delegatecall. Because delegatecall preserves msg.sender and msg.value, every inner call could access the same ETH payment as if it were newly provided.

This allowed an attacker to call commitEth() multiple times in one transaction, but only send ETH once. Each commitment credited the attacker with a full bid, even though no new ETH was supplied.

The bug was made worse by the auction's refund logic: once the auction's hard cap was hit, any "excess ETH" was automatically refunded instead of rejecting transactions. This meant an attacker could use the reuse bug to artificially inflate contributions, trigger refunds, and drain real ETH from the contract β€” endangering ~$350M.

A Better Explanation (With Simplified Example)

Intended Behavior

  1. User bids with ETH:

    function commitEth() external payable {
        require(msg.value > 0, "no eth");
        commitments[msg.sender] += msg.value;
        totalRaised += msg.value;
    }

    Each bid increases the user's commitment and the auction's total raised.

  2. Batching: BoringBatchable.batch() was intended to let users call multiple functions (like approve then commitEth) in one transaction.

  3. Refund logic: If totalRaised > hardCap, extra ETH is refunded back to the sender.

What Actually Happens (Bug)

  • batch() calls delegatecall for each calldata entry, preserving msg.value across all calls.
  • Each call to commitEth() sees the same msg.value, even though the ETH was only sent once.
  • Internal accounting (commitments[msg.sender] += msg.value) is incremented multiple times.
  • When the hard cap is reached, the auction issues refunds for "extra ETH" β€” but refunds are based on inflated accounting.

Why This Matters

  • Attacker can pretend to contribute multiple times with the same ETH.
  • Once the auction hits the hard cap, refunds allow the attacker to withdraw real ETH, potentially draining the pool.
  • Because ~$350M was raised in the Sushi MISO auction, this bug was catastrophic in potential impact β€” an attacker could have stolen all funds.

Concrete Walkthrough (Alice & Mallory)

  • Setup: Hard cap = 100 ETH. Honest bidders already contributed 99 ETH.

  • Mallory attack:

    • Calls batch([commitEth(), commitEth()]) with msg.value = 1 ETH.
    • First commitEth(): commitments[M] += 1 ETH (totalRaised = 100).
    • Second commitEth(): commitments[M] += 1 ETH again (totalRaised = 101).
    • Contract now thinks Mallory contributed 2 ETH, though only 1 ETH was ever sent.
  • Refund logic:

    • Auction detects totalRaised > hardCap and refunds the "extra ETH".
    • Refunds are calculated as if Mallory sent more than they actually did.
    • Mallory receives ETH back from the contract, effectively siphoning real ETH contributed by others.

Analogy: Imagine paying $10 at a carnival gate, but because the ticket scanner is reused for each ride, it stamps your wrist multiple times. Later, when the carnival closes, it refunds "unused tickets" based on stamps β€” and you get back more money than you paid, taken from the carnival's cashbox.

Vulnerable Code Reference

1) BoringBatchable.batch preserves msg.value across delegatecalls

(bool success, bytes memory result) = address(this).delegatecall(calls[i]);
// Each delegatecall sees the same msg.sender and msg.value

2) commitEth directly credits msg.value without consumption

function commitEth() external payable {
    require(msg.value > 0, "no eth");
    commitments[msg.sender] += msg.value;   // credited every call
    totalRaised += msg.value;               // counted multiple times
}

3) Refund logic issues ETH refunds once hard cap is exceeded

if (totalRaised > hardCap) {
    uint refund = totalRaised - hardCap;
    payable(msg.sender).transfer(refund);   // real ETH sent out
}

Recommended Mitigation

  1. Consume ETH once per transaction: On entry, convert all ETH into WETH or explicitly track cumulative consumption so msg.value cannot be reused across multiple inner calls.

    uint value = msg.value;
    // Pass explicit value per call, decrement as used
  2. Restrict batching of payable functions: Disallow calling ETH-payable functions via batch, or require msg.value == 0 inside delegatecalls.

  3. Safer refund handling:

    • Reject contributions once the hard cap is hit (instead of refunding).
    • Ensure refunds cannot exceed actual ETH received from the sender.
  4. Testing & invariants:

    • Property tests should assert: sum(commitments) <= total ETH received.
    • Fuzz batch calls with multiple commitEth() to detect over-crediting.

Pattern Recognition Notes

  • Global variables reused per call: msg.value is shared across delegatecalls β€” must not be treated as fresh input.
  • Batch + delegatecall hazards: Preserving msg.sender and msg.value across multiple calls breaks assumptions of per-call payment or access control.
  • Refund amplification: Logic that refunds "excess" after hard cap can turn accounting errors into real money loss.
  • Defensive ETH handling: Convert to WETH or enforce explicit value parameters to prevent invisible ETH reuse.

Quick Recall (TL;DR)

  • Bug: batch + delegatecall lets attacker reuse msg.value across multiple commitEth() calls.
  • Impact: Contract credits multiple bids for a single ETH payment. Refund logic then allows attacker to withdraw real ETH.
  • Fix: Don't rely on msg.value per call; restrict batch for payable functions; enforce hard cap strictly.