SushiSwap MISO DutchAuction Batchable msg.value Refund-Drain Near Miss (2021-08-17)

  • Name: SushiSwap MISO DutchAuction batchable msg.value refund-drain near miss
  • Date: 2021-08-17
  • Protocol: SushiSwap MISO
  • Chain: Ethereum
  • Category: ambient payment-context reuse / msg.value replay / delegatecall batch-composition failure / refund-path drain / whitehat near miss
  • Loss: no realized theft, but samczsun estimated about 109,000 ETH — roughly $350M at the time — was exposed in the live Dutch auction contract
  • Summary: SushiSwap’s MISO launchpad mixed two individually reasonable components into a dangerous system. DutchAuction.commitEth() priced participation from raw msg.value, calculated how much of that payment fit before the auction cap, and refunded the excess. Separately, BoringBatchable.batch() let callers execute multiple delegatecalls back into the same contract. Under delegatecall, each nested call inherits the outer transaction’s msg.value. That meant one outer ETH payment could be reused across many commitEth() subcalls. Early in the batch, the auction treated that ambient ETH as repeated new commitments; once the auction approached or exceeded its cap, later subcalls treated the same ambient ETH as fully refundable and transferred it back out. In the extreme case Sam described, sending 1 ETH into a batch with only 1 ETH of capacity left could make the first subcall consume that last slot and every later subcall refund another 1 ETH, turning one real payment into a multi-refund drain. Whitehats and Sushi contributors privately coordinated a response, the active Dutch auction sold out naturally, and the team finalized it before anyone exploited the bug onchain.
  • Trigger / entry point:
    • The live vulnerable contract was the MISO Dutch auction at 0x4c4564a1FE775D97297F9e3Dc2e762e0Ed5Dda0e, which samczsun inspected directly on Etherscan.
    • DutchAuction inherited BoringBatchable, making batch(bytes[] calls, bool revertOnFail) available on the auction contract itself.
    • batch() used address(this).delegatecall(calls[i]) for each nested payload, preserving the outer call’s msg.sender and msg.value across all subcalls.
    • The payable participation path commitEth(address payable _beneficiary, bool ...) trusted raw msg.value as the ETH presented by that logical commitment.
  • Root cause:
    • MISO implicitly treated msg.value as a fresh per-commit payment proof even when the same outer call frame could execute many commitments.
    • BoringBatchable.batch() was not broken in isolation, and commitEth() was not obviously broken in isolation. The exploit appeared only when the two were composed.
    • Inside commitEth(), lines 272-281 computed ethToTransfer = calculateCommitment(msg.value) and ethToRefund = msg.value - ethToTransfer, then transferred the refund to the beneficiary.
    • Because delegatecall preserved the same ambient msg.value for every batched subcall, each commitEth() invocation re-ran the same commitment-and-refund logic as if fresh ETH had arrived again.
    • The hard-cap / partial-fill logic made the bug much worse: once remaining auction capacity was small or zero, repeated subcalls converted reused msg.value into repeated refunds instead of just free commitments.
  • Exploit path:
    1. The attacker prepared a batch() call containing many encoded commitEth() payloads targeting the same live Dutch auction.
    2. They sent only one outer ETH payment with the batch() transaction.
    3. BoringBatchable.batch() iterated through the payloads and delegatecalled into commitEth() for each one, preserving the same msg.value every time.
    4. On each subcall, commitEth() computed ethToTransfer = calculateCommitment(msg.value) from that reused ambient payment context.
    5. If auction capacity still remained, some early subcalls were credited as if the attacker had paid again; once the auction was near full, later subcalls computed ethToTransfer as a small residual or 0 and ethToRefund as most or all of the same msg.value.
    6. Each of those later subcalls independently executed _beneficiary.transfer(ethToRefund), so the same outer ETH payment could generate many refunds.
    7. Sam’s concrete illustration was the sharpest version: with only 1 ETH of auction capacity left, a batched call that reused 1 ETH across one hundred commitEth() subcalls could let the first call fill the final 1 ETH slot and the remaining ninety-nine calls each refund 1 ETH, extracting 99 ETH net.
  • What the attacker needed to be true:
    • The target MISO auction needed to accept ETH through commitEth() and expose the inherited batch() helper.
    • batch() needed to preserve outer call context for nested calls, which delegatecall does by design.
    • commitEth() needed to use raw msg.value rather than a consumed internal payment budget.
    • The auction needed either remaining capacity or a cap-refund branch so repeated subcalls could monetize the reused payment context.
    • Enough ETH had to remain in the contract — from ongoing commitments or auction balances — for the refund leg to be cash-settled.
  • Technical anatomy worth remembering:
    • The key preserved lesson is not delegatecall dangerous in the abstract. The important mistake was using ambient call-level payment context as if it were per-subcall economic truth.
    • The ERC-20 path was meaningfully safer here: commitTokensFrom() called _safeTransferFrom(...) for each commitment, so each logical commitment required a fresh token transfer. The native-asset path reused msg.value instead.
    • The refund branch is what turned a free-bid bug into a treasury-drain bug. Without refunds, the issue could still corrupt auction outcomes; with refunds, it could directly extract ETH.
    • This is a clean composition failure. Reviewers can audit a batch helper and a payable commitment function separately and still miss the emergent exploit path at the boundary.
    • The incident also produced an operationally useful mitigation pattern for the still-live MISO batch auction: use an external validation hook to check that actual ETH balances matched total commitments, blocking fake batched commitments without changing auction state.

Three payment-provenance promotions this incident collapsed

  1. A payable ETH commitment-shaped action existed at all
    • Through BoringBatchable.batch(), MISO could still reach commitEth() many times inside one outer transaction, and each nested delegatecall still arrived carrying a valid-looking msg.sender plus the same ambient msg.value.
    • At this stage the strongest true fact was only a batched subcall reached the normal ETH commitment function while one outer payment context still existed, not this logical commitment brought fresh ETH consideration of its own.
  2. That action was promoted into canonical truth that this exact logical bid had paid fresh ETH into the auction
    • This was the decisive failure. commitEth() treated raw msg.value as if it were subcall-local payment proof even though batching let many logical commitments reuse the same outer call frame.
    • MISO therefore collapsed a nested call can still read one ambient payment context and this specific commitment independently contributed new ETH to auction accounting into the same accounting truth, so repeated subcalls were credited as if repeated funding had arrived.
  3. That accepted payment truth still deserved independent refund authority against live auction ETH
    • Once the hard-cap logic saw little or no remaining capacity, later batched subcalls still computed their own ethToRefund = msg.value - ethToTransfer from the reused payment context and transferred ETH out separately.
    • The system therefore preserved almost no boundary between one real payment already got consumed by an earlier logical commitment and later logical commitments may still cash out their own refund story, which is why the bug escalated from fake participation into a potential treasury drain.
  • Representative contracts and code paths:
    • Vulnerable live Dutch auction contract: 0x4c4564a1FE775D97297F9e3Dc2e762e0Ed5Dda0e
    • BoringBatchable.batch() delegatecall loop: contracts/Utils/BoringBatchable.sol lines 35-43 in commit 44b51769b614826c50d8f5c55cb60094fa1c7bad
    • DutchAuction.commitEth() refund logic: contracts/Auctions/DutchAuction.sol lines 261-283 in the same commit
    • DutchAuction.calculateCommitment() cap logic: contracts/Auctions/DutchAuction.sol lines 362-367
  • Response / mitigation:
    • Samczsun verified the issue, escalated it through Georgios Konstantopoulos to Sushi contributors, and a small war room formed with Sushi and Immunefi participants.
    • The team considered three options: do nothing, whitehat-rescue funds using the exploit, or buy out and finalize the live Dutch auction.
    • They prepared a flash-loan-assisted rescue / finalization contract for the live Dutch auction in case they had to force the close themselves.
    • Before the rescue had to be executed, the live Dutch auction sold out naturally; the team then finalized it immediately, moving funds out of the vulnerable auction contract and neutralizing the highest-risk exposure.
    • A second live MISO batch auction shared the broader fake-commitment issue but lacked the Dutch auction’s refund path, so it posed outcome-integrity risk rather than direct treasury-drain risk. The team designed a points-list-based validation hook that checked ETH balances against commitments, then ultimately let that auction finish naturally because no direct funds-at-risk path remained.
  • Key transactions and artifacts:
    • No public theft transaction hashes are known because the issue was privately disclosed and mitigated before exploitation.
    • The key public artifacts are the vulnerable live auction contract, Samczsun’s reconstruction, Mudit Gupta’s war-room writeup, and the archived MISO source code showing batch() and commitEth().

Pattern tags

  • ambient-payment-context-reuse
  • delegatecall batch payment replay / inherited BoringBatchable.batch() preserved one outer msg.value across many nested commitEth() calls
  • Refund-amplification branch / once auction capacity approached zero, reused payment context turned into repeated ETH refunds rather than only fake commitments
  • Component-composition invariant failure / batch() and commitEth() looked locally reasonable, but the composed system no longer had one-payment-per-commitment semantics

Why the exploit worked

  • MISO let one outer transaction masquerade as many logical ETH commitments. BoringBatchable.batch() executed delegatecall(address(this), ...), so every nested commitEth() inherited the same msg.value and msg.sender as the parent transaction instead of seeing a fresh payment boundary for each commitment.
  • commitEth() used ambient call context as if it were subcall-local economic truth. The function computed ethToTransfer = calculateCommitment(msg.value) and ethToRefund = msg.value - ethToTransfer, which is only safe when one payable commitment consumes one call frame. Under batching, that same formula replayed the same outer ETH across every nested commitment.
  • The hard-cap refund path converted semantic confusion into direct extractable value. Early batched calls could still be read as fake commitments, but once remaining auction capacity shrank, later subcalls independently refunded most or all of the reused msg.value. That is why the live Dutch auction carried treasury-drain risk rather than merely bad auction accounting.
  • The exploit existed at the system boundary, not inside one obviously broken function. Mudit’s war-room note and Sam’s writeup are useful precisely because they preserve the composed lesson: a helper that safely batches ordinary calls and a payable auction function that safely handles ordinary users can still become unsafe when the helper erases the payment provenance the auction relied on.

Detection and audit pivots

  • Treat every inherited batch, multicall, module executor, or router self-delegatecall helper as part of the threat model for every payable function in the contract. If a nested call can still read outer msg.value, then any logic keyed directly off msg.value deserves adversarial composition review.
  • Distinguish commitment inflation from refund amplification. MISO is the reminder that the refund branch is often the economically decisive leg: once the contract can independently compute msg.value - acceptedAmount several times, the bug graduates from free participation to actual ETH extraction.
  • Compare native-asset and tokenized payment paths. If the ERC-20 path proves each commitment with transferFrom() but the ETH path just re-reads msg.value, the native path is carrying a weaker payment-provenance model even before batching is added.
  • Fuzz capacity-edge cases, not only fully open auctions. The most dangerous states are often almost full, hard cap reached, or small residual capacity because that is where a reused payment context starts emitting refunds rather than only bogus commitments.
  • Ask the composition question bluntly: can one real incoming payment satisfy more than one logical entitlement or refund story in one transaction? If the answer is yes, or the contract cannot point to an internal consumed-value ledger, then the auction still lacks a real payment-provenance boundary.

Failure-prevention checklist

  • Treat every inherited batch or multicall helper as part of the solvency surface for every payable function on the contract. If delegatecall(address(this), ...) can reach commitEth(), the review scope must include the composed system rather than the components in isolation.
  • Compute commitment and refund rights from an internally consumed payment budget, not from raw msg.value re-read inside each nested call. Once one subcall allocates some ETH, later subcalls should only be able to use what remains.
  • Forbid or specially gate value-sensitive functions under self-delegatecall batching. If commitEth() is only safe as a top-level entrypoint, encode that restriction onchain instead of assuming users or frontends will not batch it.
  • Keep native-asset and ERC-20 commitment semantics aligned. If commitTokensFrom() proves each commitment with a fresh token transfer but commitEth() only consults inherited call context, the ETH path should be presumed higher risk until normalized or wrapped.
  • Treat hard-cap and partial-fill refund branches as first-class loss surfaces. Repeated refund computation from one ambient payment is often more dangerous than repeated free participation.
  • Maintain an independent balance-truth check or circuit breaker for live auctions. MISO’s war-room mitigation relied on comparing real ETH held to commitments, which is exactly the kind of invariant the contract should already know how to enforce.

Audit prompts

  • Which payable functions can be reached through batch, multicall, module execution, or self-delegatecall, even if the author mentally modeled them as one-shot entrypoints?
  • Does any nested path read raw msg.value and then independently compute acceptedAmount and refundAmount without debiting a transaction-local budget first?
  • Can the same outer payment satisfy more than one commitment story, or worse, more than one refund story, in a single transaction?
  • What happens near the hard cap or with only a tiny amount of auction capacity left? Is that edge state where the bug becomes cash extraction instead of merely free participation?
  • If the ERC-20 path requires a fresh transfer per commitment but the ETH path does not, what exact justification keeps those two branches economically equivalent?
  • If the protocol needed an emergency validation hook tomorrow, what invariant would it enforce: actual balance equals commitments, one commitment per caller, per-transaction payment budget consumption, or something weaker?

Lessons that should already be standard

  • Safe helper plus safe payable function can still be an unsafe contract once composition erases payment provenance.
  • Refund logic is custody logic. The moment a contract can independently compute and send msg.value - acceptedAmount, replayed payment context can become a direct treasury-drain path.
  • delegatecall preserves more than control flow; it preserves authority context like msg.sender and msg.value, so any accounting keyed to those globals has to be reviewed compositionally.
  • Native-asset convenience paths usually carry more hidden semantics risk than token-transfer paths. If wrapping to WETH would make the design obviously safer, that is a useful warning, not mere implementation annoyance.
  • Operational hooks that compare live balances to commitments are not just incident-response tricks; they expose the real invariant the system should have been enforcing all along.

Why this incident matters

  • It is one of the clearest preserved examples of a safe component + safe component = unsafe system failure in DeFi contract composition.
  • It deserves to sit beside more famous balance-delta and reentrancy bugs because the preserved invariant is equally reusable: one real payment was allowed to masquerade as many logical payments.
  • It also cleanly separates free participation from cash drain. The hard-cap refund design is what escalated reused msg.value from auction corruption into immediate ETH extraction.

Internal linkages

Control surface