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: 10/120
Findings: 3
Award: $695.82
🌟 Selected for report: 0
🚀 Solo Findings: 0
690.3741 USDC - $690.37
asD::withdrawCarry
assumes that the exchange rate returned by the cNote
contract will be scaled by 1e28
, but in reality it will be only scaled by 1e18
. It will cause withdrawCarry
to always revert with Integer Underflow, which means that owner won't ever be able to withdraw the accrued interest.
When we analyse the asD::withdrawCarry
we will see the following code:
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"); } // Technically, _amount can still be 0 at this point, which would make the following two calls unnecessary. // But we do not handle this case specifically, as the only consequence is that the owner wastes a bit of gas when there is nothing to withdraw uint256 returnCode = CErc20Interface(cNote).redeemUnderlying(_amount); require(returnCode == 0, "Error when redeeming"); // 0 on success: https://docs.compound.finance/v2/ctokens/#redeem IERC20 note = IERC20(CErc20Interface(cNote).underlying()); SafeERC20.safeTransfer(note, msg.sender, _amount); emit CarryWithdrawal(_amount); }
The comment in the first line says that the result returned by the exchangeRateCurrent
will be "Scaled by 1 * 10^(18 - 8 + Underlying Token Decimals), i.e. 10^(28) in our case". This statement has its source in the Compound docs, where it's written that:
RETURN: The current exchange rate as an unsigned integer, scaled by 1 * 10^(18 - 8 + Underlying Token Decimals).
It is indeed true for CErc20
and CEther
contracts deployed on Ethereum (try for example exchangeRateStored
on cETH), because they indeed have 8
decimals (see decimals
on the cETH).
The problem is that the CNote contract has 18 decimals, which means that the result returned by exchangeRateCurrent
will be scaled by 10^(18 - 18 + 18) = 1e18
, not 1e28
. Hence the formula given in the Compound docs doesn't apply here and we have to substitute 8
with 18
in it.
withdrawCarry
will always revert when calculating maximumWithdrawable
, hence owner will never be able to receive the income. It's because (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) / 1e28
will be less than totalSupply()
in almost all cases, unless the contract has ~$10 000 000 000
per each token from the totalSupply()
which is irrational.
I'm submitting this issue as High because the entire point of the asD
contract is to mint cNote
s out of received Note
tokens in order to get profits and the only way to claim the profits is by the withdrawCarry
function.
The address of the CNote
contract is 0xEe602429Ef7eCe0a13e4FfE8dBC16e101049504C
, which can be confirmed in the Canto docs.
We can either click exchangeRateStored
here to quickly check that the result is indeed scaled by 1e18
.
More detailed proof is shown below.
When we look at the CNote
implementattion contract, we will see that when exchangeRateCurrent
is called, the following code executes:
function exchangeRateCurrent() override public nonReentrant returns (uint) { accrueInterest(); return exchangeRateStored(); }
So, the value returned by exchangeRateStored
is returned. The code of exchangeRateStored
is shown below:
function exchangeRateStored() override public view returns (uint) { return exchangeRateStoredInternal(); }
So, another function is called - exchangeRateStoredInternal
, which code looks as follows:
/** * @notice Calculates the exchange rate from Note to cNote * @dev This function does not accrue efore calculating the exchange rate * @return calculated exchange rate scaled by 1e18 */ function exchangeRateStoredInternal() virtual internal view returns (uint) { uint _totalSupply = totalSupply; if (_totalSupply == 0) { /* * If there are no tokens minted: * exchangeRate = initialExchangeRate */ return initialExchangeRateMantissa; } else { /* * Otherwise: * exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply */ uint totalCash = getCashPrior(); uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves; uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply; return exchangeRate; } }
As can be seen from the comment above the function, the value returned is scaled by 1e18
, not 1e28
.
VS Code, Canto block explorer
Change 1e28
in asD::withdrawCarry
to 1e18
.
Under/Overflow
#0 - c4-pre-sort
2023-11-18T05:14:44Z
minhquanym marked the issue as duplicate of #227
#1 - c4-judge
2023-11-28T22:55:38Z
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
Anybody, including arbitrage bots, can frontrun every single call to Market::buy
and immediately after a user buys some tokens, they can call sell the same amount of tokens and get profit in the expense of the user.
Users can buy tokens on the market using the Market::buy
function:
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); [...] }
As we see, it calls getBuyPrice
, which contains the following code:
function getBuyPrice(uint256 _id, uint256 _amount) public view returns (uint256 price, uint256 fee) { // If id does not exist, this will return address(0), causing a revert in the next line address bondingCurve = shareData[_id].bondingCurve; (price, fee) = IBondingCurve(bondingCurve).getPriceAndFee(shareData[_id].tokenCount + 1, _amount); }
As can be noted, it returns the values returned by bondingCurve.getPriceAndFee(shareData[_id].tokenCount + 1, _amount)
. getPriceAndFee
looks like this:
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; } }
As can deduced from the code above, token price is dependent on the current supply of tokens and is roughly (not counting fees) equal to priceIncrease * (shareCount + (shareCount + 1) + ... + (shareCount + amount - 1))
. So, the more tokens are in total supply, the higher is the price of new tokens.
The sell
function that enables users to sell tokens works exactly in the same way - if we ignore fees, then buying k
tokens and selling them immediately would not impact user's balance.
The problem is that calls to buy
may be frontrunned. In order to see this, consider the following scenario (for simplicity, we ignore fees):
10
tokens.10
tokens for himself. He pays priceIncrease * (1 + 2 + 3 + ... + 10) = 55 * priceIncrease
.priceIncrease * (11 + 12 + ... + 20) = 155 * priceIncrease
.155 * priceIncrease
. Attacker earned 100 * priceIncrease
and if Alice decides to sell her tokens, she will sell each token for a significantly cheaper price than she paid when she bought it.It's also worth to notice that arbitrage bots can even perform sandwitch attacks on themselves. To see this consider this example (we avoid fees once again and we assume that priceIncrease = 1
for simplicity):
10
tokens.attacker1
wants to frontrun her and creates a batch of transactions where he first buys, then Alice buys and the he sells tokens.attacker2
sees this and creates another batch of transactions where he first buys tokens, then the batch created by attacker1
executes and then he sells.buy
of 10
tokens performed by attacker2
- he pays 55
.attacker1
buys 10
tokens - he pays 155
.10
tokens - she pays 255
.attacker1
sells 10
tokens for 255
- he profits 100
.attacker2
sells 10
tokens for 155
- he profits 100
.255
and she can now sell for only 55
.We can consider even more attackers in our scenario and each additional one will make Alice to lose even more money.
Note: It is also possible that users will buy for higher prices by an accident, if someone unintentionally frontruns them with buy
or with burning NFT increasing token total supply. I'm not reporting it as a separate issue since it's less severe than the issue currently reported and the root cause is the same.
The impact is that each user may be frontrunned - it means that there will be a lot of arbitrage bots that will rob users from their money. Moreover, the amount lost for users can be arbitrarily high - since arbitrage bots can frontrun themselves, they may inflate prices to astronomical levels, so even if user only buys 1
token, he may lose a lot of money - the only thing that limits the attackers from stealing unbounded amount of money is block gas limit.
Each user who buys some tokens will not be able to sell them for a good price (at least until some new users enter the protocol and increase the token price) - it means that each user will lose money right away at the beginning, just after buy
is executed.
Since the attack is trivial to execute and the fact that users are lost even after the first interaction with the protocol (which will make them reluctant to use it at all) and they can lose a lot of money, I'm submitting the issue as High.
Please add the following function to the MockERC20
in Market.t.sol
:
function mint(address to, uint amount) public { _mint(to, amount); }
Please add and run the following test:
function testSandwitch() public { // create the attacker account address attacker = address(0x1234); // mint some tokens so that users can use them token.mint(alice, 1e19); token.mint(attacker, 1e19); // approve tokens to the market vm.prank(alice); token.approve(address(market), type(uint).max); vm.prank(attacker); token.approve(address(market), type(uint).max); // create share so that users can but tokens on the market testCreateNewShare(); // now, alice plans to buy 10 tokens, but attacker frontruns her and buys 10 tokens before her vm.prank(attacker); market.buy(1, 10); vm.prank(alice); market.buy(1, 10); // just after alice buys, attacker sells his tokens and receives a profit vm.prank(attacker); market.sell(1, 10); assert(token.balanceOf(attacker) > 1e19); assert(token.balanceOf(alice) < 1e19); console.log("token.balanceOf(attacker)", token.balanceOf(attacker)); console.log("token.balanceOf(alice)", token.balanceOf(alice)); }
VS Code
Add the maxAmount
parameter to the buy
function so that users can specify the maximum amount of money they are willing to pay for tokens. While sandwitch attacks will still be possible unless users specify very strict amounts, their impact will be significantly reduced.
Other
#0 - c4-pre-sort
2023-11-18T11:57:07Z
minhquanym marked the issue as duplicate of #12
#1 - c4-judge
2023-11-28T23:14:14Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-28T23:38:08Z
MarioPoneder marked the issue as satisfactory
🌟 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
Since tokens will be more expensive to buy when the total supply increases, some malicious users can buy a lot of tokens right after protocol deployment just in order to dump them later on for a higher price when new users arrive. These new users will then be unable to sell their tokens for an acceptable price.
The current mechanism of calculating token prices in Market::buy
and Market::sell
makes token price to increase a lot when token supply increases - for example minting 1000
th token will be roughly 1000
times more expensive than buying the first one. it can be verified by analysing the LinearBondingCurve::getPriceAndFee
:
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; } }
It encourages users to buy a lot of tokens right after protocol deployment. This way, when new users enter, they (early users who bought a lot of tokens at the beginning) can dump their tokens and receive profit. But because of their actions, new users will pay significantly more for tokens than they would normally do.
In order to see this, consider the scenario:
1000
tokens.1001 * priceIncrease
for the first NFT instead of 1 * priceIncrease
, which is over 1000
more.1000 * priceIncrease
each time.Current fee calculation mechanism encourages unhealthy behaviour among users - users are incentivised to speculate and buy tokens early on only in order to sell them later, without really using the protocol (the additional incentive is that users with high token balance will get significant amount of protocol fees). This behaviour also harms innocent users discouraging them from using the protocol at all. In the scenario that I have described in the previous section, no "normal" user will be able to buy tokens for prices lower than 1000 * priceIncrease
- only attacker will do so.
Note that attacker doesn't risk a lot here, as he will be able to sell his tokens at any time, in the worst case, for the price he initially bought them - he will only pay the fees, but they aren't high enough in order to discourage such attacks, especially that he will get a lot of fees paid by other users since he has a lot of tokens.
Note: This issue is different than the one about possible sandwitch attack that I submitted earlier. First of all, because the fix for that issue doesn't fix the problem described here, and secondly, this issue is about the design flaw that encourages unhealthy behaviours among users, that makes other users to be harmed.
First, please add the following function to the MockERC20
in Market.t.sol
:
function mint(address to, uint amount) public { _mint(to, amount); }
Then, please put the following test to Market.t.sol
and run it:
function testUnhealthy() public { uint INITIAL_BALANCE = 1e22; // create the attacker account address attacker = address(0x1234); // mint some tokens so that users can use them token.mint(alice, INITIAL_BALANCE); token.mint(attacker, INITIAL_BALANCE); // approve tokens to the market vm.prank(alice); token.approve(address(market), type(uint).max); vm.prank(attacker); token.approve(address(market), type(uint).max); // create share so that users can but tokens on the market testCreateNewShare(); // attacker buys first 1000 tokens vm.prank(attacker); market.buy(1, 1000); // alice has to buy for a higher price then she normally would vm.prank(alice); market.buy(1, 10); // attacker can either wait for more users or just sell some of his tokens in order to receive some profit // assume that he waits longer // we now simulate another users entering the protocol - in order to make it simple, we will do it by // buying another 990 tokens by alice - normally many users would do it over longer time interval vm.prank(alice); market.buy(1, 990); // attacker sell all of his tokens (he can do it gradually over time, but in order to keep things // simple, we will simulate this by selling all of his tokens at once) vm.prank(attacker); market.sell(1, 1000); assert(token.balanceOf(attacker) > INITIAL_BALANCE); console.log("token.balanceOf(attacker)", token.balanceOf(attacker)); }
VS Code
It's not so easy to fix this problem as incentives for the early users should still exist.
The best fix that comes to my mind is to limit the amount of tokens that users can buy for some time after each share has been created (say 1
week) to 1
and after that time allow anybody to buy as many tokens as they want. It will still be possible to buy tokens from many accounts at the beginning, but it will be more costly:
1000
tokens, he would have to create 1000
accounts, send some tokens for each of these accounts and then make 1000
buy
transactions - doing so many transactions will be costly and annoying, so it will deter the attackers. Moreover, if the attacker even managed to get 1000
tokens (one per each account), then he will not benefit from protocol fees (as gas fees for 1000
calls to claimHolderFee
will consume the entire fees earned).Other
#0 - c4-pre-sort
2023-11-20T17:10:49Z
minhquanym marked the issue as duplicate of #467
#1 - MarioPoneder
2023-11-29T13:54:39Z
I acknowledge the validity of those concerns. However, this is rather a design choice than a design flaw or a bug of the protocol per se, therefore QA seems appropriate.
#2 - c4-judge
2023-11-29T13:54:47Z
MarioPoneder marked the issue as not a duplicate
#3 - c4-judge
2023-11-29T13:54:51Z
MarioPoneder changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-11-29T22:33:33Z
MarioPoneder marked the issue as grade-b
#5 - bart1e
2023-11-30T19:12:35Z
@MarioPoneder, first of all, I would like to thank you a lot for reviewing this submission and sharing your point of view.
However, I would still like to argue that it is a design flaw rather than design choice. The points I have raised are:
If it is a design choice made by the Sponsors, then it has the following implications:
I think that these implications are quite severe and it's not what Sponsors intended (hence I don't think it's a design choice). I think that the intended implications are as follows:
However, these implications will only hold when the issue that I reported is fixed, because otherwise, the first set of implications unfortunately apply as I have shown in my report and highlighted above.
Last, but not least, the description for Medium severity that can be found in the docs state:
I believe that the leak of value is both for users (when dishonest user who bought a lot of tokens at the beginning dumps them) and for the protocol (as users may be reluctant to use the protocol, because of such unhealthy behaviour).
Thank you once again and I would be very grateful, if you could review my arguments on this issue.
#6 - MarioPoneder
2023-12-01T00:25:55Z
Thank you for your comment!
Again, I acknowledge the validity of your concerns, see also #294.
Nevertheless, the price computation in LinearBondingCurve
bonding curve is intended design.
Even if many wardens disagree with the design choice, there is no underlying bug/vulnerability and the protocol works as intended.
Therefore I cannot grade this as a bug.
The sponsor might want to provide further info about this @OpenCoreCH
#7 - OpenCoreCH
2023-12-02T14:39:07Z
The points that are brought up here apply to every protocol with a bonding curve (friend.tech, song.tech) and you can definitely discuss the pros and cons of such a design, which is an interesting discussion to have (although this is probably the wrong place). I do not agree with all of the implications pointed out here:
- furthermore, since the attack is very simple, it will be exploited by bots that will always buy tokens at the beginning, before other users.
- the risk, that users who observe such unhealthy practices or experience them by themselves, will be reluctant to buy any new tokens at all is an accepted risk and is not considered a problem
So on the one hand, bots will always buy in the beginning, but on the other hand, no more users will buy? Why would the bots then continue to buy in the beginning, this is a risk for them because they need to pay fees. But if no bots buy, wouldn't it then be attractive again for users to buy following this logic?
In protocols with such a bonding curve, it is of course true that "early" buyers are awarded. However, what actually is early? You do not know that when buying, if there is only one buyer for a market, even the first buyer is not early (and will make a loss), if there are 20000, buyer number 1,000 is still early and will make a profit.