Platform: Code4rena
Start Date: 13/11/2023
Pot Size: $24,500 USDC
Total HM: 3
Participants: 120
Period: 4 days
Judge: 0xTheC0der
Id: 306
League: ETH
Rank: 2/120
Findings: 4
Award: $902.93
🌟 Selected for report: 0
🚀 Solo Findings: 0
690.3741 USDC - $690.37
The asD.withdrawCarry()
method is mainly used to withdraw profits.
An important step in this process is the calculation of maximumWithdrawable
.
This variable is used to prevent the owner
from withdrawing the note
deposited by the user, ensuring a 1:1
exchange.
The calculation formula for maximumWithdrawable
is as follows:
function withdrawCarry(uint256 _amount) external onlyOwner { uint256 exchangeRate = CTokenInterface(cNote).exchangeRateCurrent(); // Scaled by 1 * 10^(18 - 8 + Underlying Token Decimals), i.e. 10^(28) in our case // The amount of cNOTE the contract has to hold (based on the current exchange rate which is always increasing) such that it is always possible to receive 1 NOTE when burning 1 asD uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) / @> 1e28 - totalSupply(); if (_amount == 0) { _amount = maximumWithdrawable; } else { require(_amount <= maximumWithdrawable, "Too many tokens requested"); }
Currently, it is assumed that the number of digits is 28
, which is actually misleading in the documentation.
Let's see how many digits there actually are.
Online address: https://tuber.build/address/0xEe602429Ef7eCe0a13e4FfE8dBC16e101049504C?tab=read_contract
block:6896358
exchangeRateCurrent = 1004019984703570700 = 1.004e18
Here we can see the official explanation of this document. Currently, cNote
should be 1e18
.
https://github.com/compound-finance/compound-protocol/pull/167
This isn't quite right and also we wouldn't change the comments on already deployed contracts. But to explain: the exchange rate is somewhat arbitrary, and although its interpretation will depend on the decimals of the cToken and the decimals of the underlying, once you have a desired initial exchange rate, the mantissa is simply scaled by 1e18.
The incorrect use of 28 digits
for calculation causes maximumWithdrawable
to be far less than the actual value
making it impossible to withdraw profits, and the funds are locked in the contract.
function withdrawCarry(uint256 _amount) external onlyOwner { uint256 exchangeRate = CTokenInterface(cNote).exchangeRateCurrent(); // Scaled by 1 * 10^(18 - 8 + Underlying Token Decimals), i.e. 10^(28) in our case // The amount of cNOTE the contract has to hold (based on the current exchange rate which is always increasing) such that it is always possible to receive 1 NOTE when burning 1 asD uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) / - 1e28 - + 1e18 - totalSupply(); if (_amount == 0) { _amount = maximumWithdrawable; } else { require(_amount <= maximumWithdrawable, "Too many tokens requested"); }
Decimal
#0 - c4-pre-sort
2023-11-19T12:25:12Z
minhquanym marked the issue as duplicate of #227
#1 - c4-judge
2023-11-28T22:58:43Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: rvierdiiev
Also found by: 0x175, 0x3b, 0xMango, 0xarno, 0xpiken, Bauchibred, DarkTower, ElCid, Giorgio, HChang26, Kose, KupiaSec, Madalad, PENGUN, Pheonix, RaoulSchaffranek, SpicyMeatball, T1MOH, Tricko, Udsen, Yanchuan, aslanbek, ast3ros, bart1e, bin2chen, chaduke, d3e4, deepkin, developerjordy, glcanvas, inzinko, jasonxiale, jnforja, mahyar, max10afternoon, mojito_auditor, neocrao, nmirchev8, openwide, osmanozdemir1, peanuts, pep7siup, peritoflores, pontifex, rice_cooker, rouhsamad, t0x1c, tnquanghuy0512, turvy_fuzz, twcctop, ustas, vangrim, zhaojie, zhaojohnson
1.3743 USDC - $1.37
in market.sol
Whether it's buy()
,sell()
, or mintNFT()
, the price is calculated in real-time.
It is affected by shareData[id].tokenCount
.
The larger the tokenCount
, the more expensive it is, and the smaller the tokenCount
, the cheaper it is.
function buy(uint256 _id, uint256 _amount) external { require(shareData[_id].creator != msg.sender, "Creator cannot buy"); @> (uint256 price, uint256 fee) = getBuyPrice(_id, _amount); // Reverts for non-existing ID function getBuyPrice(uint256 _id, uint256 _amount) public view returns (uint256 price, uint256 fee) { ... @> (price, fee) = IBondingCurve(bondingCurve).getPriceAndFee(shareData[_id].tokenCount + 1, _amount); } contract LinearBondingCurve is IBondingCurve { // By how much the price increases per share, provided in the token decimals uint256 public immutable priceIncrease; constructor(uint256 _priceIncrease) { priceIncrease = _priceIncrease; } function getPriceAndFee(uint256 shareCount, uint256 amount) external view override returns (uint256 price, uint256 fee) { for (uint256 i = shareCount; i < shareCount + amount; i++) { @> uint256 tokenPrice = priceIncrease * i; price += tokenPrice; fee += (getFee(i) * tokenPrice) / 1e18; } }
Currently, buy()
and sell()
only pass in two parameters: _id
, amount
.
This a problem. The price that users see on the front-end UI may be much different by the time the transaction is executed.
For example:
When the user sees it on the UI: tokenCount = 1000
, price=10
After submitting the transaction, the price may have soared to 20
due to a whale buying a lot.
After the transaction is executed, someone may have sold a lot, causing the price to drop back to 5
.
In this case, users may pay a lot more, and may even suffer from sandwich attacks.
It is recommended to add maxPrice
and minPrice
similar to the slippage in swap()
.
Note: mintNFT() also has a similar problem.
The price may change without the user's expectation, leading to fund loss
It is recommended to add maxPrice
and minPrice
similar to the slippage in swap()
.
- function buy(uint256 _id, uint256 _amount) external { + function buy(uint256 _id, uint256 _amount , uint256 maxPrice) external { ... - function sell(uint256 _id, uint256 _amount) external { + function sell(uint256 _id, uint256 _amount, uint256 minPrice) external {
MEV
#0 - c4-pre-sort
2023-11-18T10:00:44Z
minhquanym marked the issue as duplicate of #12
#1 - c4-judge
2023-11-28T23:29:20Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: Krace
Also found by: 0xAadi, 0xpiken, AS, D1r3Wolf, PENGUN, SpicyMeatball, Yanchuan, bin2chen, d3e4, ether_sky, glcanvas, immeas, lanrebayode77, leegh, mojito_auditor, rvierdiiev, tnquanghuy0512
207.1122 USDC - $207.11
In Market.buy()
, when a user makes a purchase, the user's rewards
are settled and the current transaction fee is allocated. The steps are as follows:
fee
by getBuyPrice()
_getRewardsSinceLastClaim()
_splitFees()
rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled
The code is as follows:
function buy(uint256 _id, uint256 _amount) external { require(shareData[_id].creator != msg.sender, "Creator cannot buy"); (uint256 price, uint256 fee) = getBuyPrice(_id, _amount); // Reverts for non-existing ID SafeERC20.safeTransferFrom(token, msg.sender, address(this), price + fee); // The reward calculation has to use the old rewards value (pre fee-split) to not include the fees of this buy // The rewardsLastClaimedValue then needs to be updated with the new value such that the user cannot claim fees of this buy uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); // Split the fee among holder, creator and platform @> _splitFees(_id, fee, shareData[_id].tokensInCirculation); rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled; shareData[_id].tokenCount += _amount; shareData[_id].tokensInCirculation += _amount; tokensByAddress[_id][msg.sender] += _amount; if (rewardsSinceLastClaim > 0) { SafeERC20.safeTransfer(token, msg.sender, rewardsSinceLastClaim); } emit SharesBought(_id, msg.sender, _amount, price, fee); }
Normally, the third step _splitFees()
should be placed before the second step _getRewardsSinceLastClaim()
.
However, the protocol does not want to allocate this fee
to the current user, so _getRewardsSinceLastClaim()
is executed first. But the problem lies in the parameter passed to _splitFees()
, _tokenCount = shareData[_id].tokensInCirculation
, which may include the current user's share. This makes the denominator too large, and the allocated fee cannot be retrieved, because the user has already synchronized to the latest rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled
in the fourth step.
For example:
Currently, there are two users, alice
and bob
tokensByAddress[alice]=50
tokensByAddress[bob] =50
tokensInCirculation = 100
shareHolderRewardsPerTokenScaled = 1
rewardsLastClaimedValue[alice]=1
rewardsLastClaimedValue[bob]=1
Assume Alice executed buy()
(Ignore platformFee, shareCreatorFee)
Subsequently, if alice
and bob
go to get rewards
, the results are as follows:
As shown above: the fee lost 100-50 =50
The correct one should be _splitFees(_tokenCount = (tokensInCirculation - tokensByAddress[bob] ))
= _splitFees(50)
The allocation of _splitFees
does not subtract the current user's share, causing this part of the share to be lost and locked in the contract.
add to Market.t.sol
function testBuyFeeLoss() public { //0.init alice == jack == 0.001 address jack = address(3); testCreateNewShare(); token.transfer(alice, 0.1e18); token.transfer(jack, 0.1e18); vm.startPrank(alice); token.approve(address(market), 1e18); market.buy(1, 1); market.claimHolderFee(1); vm.stopPrank(); vm.startPrank(jack); token.approve(address(market), 1e18); market.buy(1, 1); market.claimHolderFee(1); vm.stopPrank(); //end of init //1. jack new buy (,uint fee) = market.getBuyPrice(1,1); vm.startPrank(alice); market.buy(1, 1); vm.stopPrank(); //2.get diff after claim uint beforeAlice = token.balanceOf(alice); uint beforeJack = token.balanceOf(jack); vm.prank(alice); market.claimHolderFee(1); vm.prank(jack); market.claimHolderFee(1); //3. check get reward will equal but not assert(fee * 3_300 / 10000 == (token.balanceOf(alice) - beforeAlice) + (token.balanceOf(jack) - beforeJack)); }
$ forge test --match-test testBuyFeeLoss Running 1 test for src/test/Market.t.sol:MarketTest [FAIL. Reason: Assertion violated] testBuyFee() (gas: 583301) Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 2.17ms Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: Encountered 1 failing test in src/test/Market.t.sol:MarketTest [FAIL. Reason: Assertion violated] testBuyFee() (gas: 583301) Encountered a total of 1 failing tests, 0 tests succeeded
There are two choices for modification:
fee
, which seems quite reasonable.function buy(uint256 _id, uint256 _amount) external { require(shareData[_id].creator != msg.sender, "Creator cannot buy"); (uint256 price, uint256 fee) = getBuyPrice(_id, _amount); // Reverts for non-existing ID SafeERC20.safeTransferFrom(token, msg.sender, address(this), price + fee); - // The reward calculation has to use the old rewards value (pre fee-split) to not include the fees of this buy - // The rewardsLastClaimedValue then needs to be updated with the new value such that the user cannot claim fees of this buy - uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); // Split the fee among holder, creator and platform _splitFees(_id, fee, shareData[_id].tokensInCirculation); + uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled;
or
2. The current user cannot get the current fee, _splitFees()
needs to subtract the current user's balance.
function buy(uint256 _id, uint256 _amount) external { require(shareData[_id].creator != msg.sender, "Creator cannot buy"); (uint256 price, uint256 fee) = getBuyPrice(_id, _amount); // Reverts for non-existing ID SafeERC20.safeTransferFrom(token, msg.sender, address(this), price + fee); // The reward calculation has to use the old rewards value (pre fee-split) to not include the fees of this buy // The rewardsLastClaimedValue then needs to be updated with the new value such that the user cannot claim fees of this buy uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); // Split the fee among holder, creator and platform - _splitFees(_id, fee, shareData[_id].tokensInCirculation); + _splitFees(_id, fee, shareData[_id].tokensInCirculation - tokensByAddress[_id][msg.sender]); rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled;
Context
#0 - c4-pre-sort
2023-11-20T06:38:33Z
minhquanym marked the issue as duplicate of #302
#1 - c4-judge
2023-11-28T22:39:43Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-28T22:41:38Z
MarioPoneder marked the issue as satisfactory
#3 - c4-judge
2023-11-28T23:54:00Z
MarioPoneder marked the issue as duplicate of #9
🌟 Selected for report: chaduke
Also found by: 0xpiken, Bauchibred, Matin, MohammedRizwan, MrPotatoMagic, OMEN, Pheonix, SandNallani, T1MOH, Topmark, ZanyBonzy, adriro, aslanbek, ayden, bareli, bart1e, bin2chen, btk, cheatc0d3, codynhat, critical-or-high, d3e4, erebus, firmanregar, hunter_w3b, jasonxiale, kaveyjoe, ksk2345, lsaudit, max10afternoon, merlinboii, nailkhalimov, osmanozdemir1, peanuts, pep7siup, pontifex, sbaudh6, shenwilly, sl1, tourist, wisdomn_, young, zhaojie
4.0797 USDC - $4.08
in createNewShare()
We need to pass in _shareName
, and it will be judged that if the same _shareName
already exists, creation is not allowed.
function createNewShare( string memory _shareName, address _bondingCurve, string memory _metadataURI ) external onlyShareCreator returns (uint256 id) { require(whitelistedBondingCurves[_bondingCurve], "Bonding curve not whitelisted"); @> require(shareIDs[_shareName] == 0, "Share already exists"); id = ++shareCount; shareIDs[_shareName] = id; shareData[id].bondingCurve = _bondingCurve; shareData[id].creator = msg.sender; shareData[id].metadataURI = _metadataURI; emit ShareCreated(id, _shareName, _bondingCurve, msg.sender); }
The problem is that the protocol can enter a mode that anyone can be ShareCreator
, when shareCreationRestricted=false
.
In this mode, if there are malicious attackers who always front-run other users and maliciously pass in the same _shareName
it will cause the other user to be unable to create any shares.
In the shareCreationRestricted=false
mode, malicious users can DOS attack, causing others to be unable to create share.
It is recommended to consider charging a certain fee for creating shares or cancel the _shareName
restriction
Context
#0 - c4-pre-sort
2023-11-18T16:28:49Z
minhquanym marked the issue as primary issue
#1 - c4-pre-sort
2023-11-18T16:31:08Z
minhquanym marked the issue as sufficient quality report
#2 - c4-sponsor
2023-11-27T11:58:11Z
OpenCoreCH marked the issue as disagree with severity
#3 - c4-sponsor
2023-11-27T11:58:16Z
OpenCoreCH (sponsor) acknowledged
#4 - OpenCoreCH
2023-11-27T12:01:50Z
There is no financial incentive for an attacker to do this and there is almost no harm for the creator (maybe some wasted gas). This finding basically applies to all permissionless protocols that enforce uniqueness on some attributes. And if we would not enforce uniqueness, I am pretty sure that there would have been findings "Attackers can create shares with the same name to trick users", so there is not really a perfect approach, just a question of tradeoffs.
#5 - MarioPoneder
2023-11-29T00:41:41Z
Due to the limited incentives and impacts of this griefing attack, QA seems most appropriate.
#6 - c4-judge
2023-11-29T00:41:49Z
MarioPoneder changed the severity to QA (Quality Assurance)
#7 - c4-judge
2023-11-29T22:38:15Z
MarioPoneder marked the issue as grade-b