- Severity: Critical
- Source: Samczsun blog β "Two Rights Might Make a Wrong"
- Affected Contract: MISO Dutch Auction (with
BoringBatchablemixin) - Vulnerability Type: Value Reuse / Accounting Manipulation / Refund Exploit
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.
-
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.
-
Batching:
BoringBatchable.batch()was intended to let users call multiple functions (likeapprovethencommitEth) in one transaction. -
Refund logic: If
totalRaised > hardCap, extra ETH is refunded back to the sender.
batch()callsdelegatecallfor each calldata entry, preservingmsg.valueacross all calls.- Each call to
commitEth()sees the samemsg.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.
- 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.
-
Setup: Hard cap = 100 ETH. Honest bidders already contributed 99 ETH.
-
Mallory attack:
- Calls
batch([commitEth(), commitEth()])withmsg.value = 1 ETH. - First
commitEth():commitments[M] += 1 ETH(totalRaised = 100). - Second
commitEth():commitments[M] += 1 ETHagain (totalRaised = 101). - Contract now thinks Mallory contributed 2 ETH, though only 1 ETH was ever sent.
- Calls
-
Refund logic:
- Auction detects
totalRaised > hardCapand 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.
- Auction detects
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.
(bool success, bytes memory result) = address(this).delegatecall(calls[i]);
// Each delegatecall sees the same msg.sender and msg.valuefunction commitEth() external payable {
require(msg.value > 0, "no eth");
commitments[msg.sender] += msg.value; // credited every call
totalRaised += msg.value; // counted multiple times
}if (totalRaised > hardCap) {
uint refund = totalRaised - hardCap;
payable(msg.sender).transfer(refund); // real ETH sent out
}-
Consume ETH once per transaction: On entry, convert all ETH into WETH or explicitly track cumulative consumption so
msg.valuecannot be reused across multiple inner calls.uint value = msg.value; // Pass explicit value per call, decrement as used
-
Restrict batching of payable functions: Disallow calling ETH-payable functions via
batch, or requiremsg.value == 0inside delegatecalls. -
Safer refund handling:
- Reject contributions once the hard cap is hit (instead of refunding).
- Ensure refunds cannot exceed actual ETH received from the sender.
-
Testing & invariants:
- Property tests should assert:
sum(commitments) <= total ETH received. - Fuzz batch calls with multiple
commitEth()to detect over-crediting.
- Property tests should assert:
- Global variables reused per call:
msg.valueis shared across delegatecalls β must not be treated as fresh input. - Batch + delegatecall hazards: Preserving
msg.senderandmsg.valueacross 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.
- Bug:
batch+delegatecalllets attacker reusemsg.valueacross multiplecommitEth()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.valueper call; restrict batch for payable functions; enforce hard cap strictly.