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: 3/120
Findings: 4
Award: $902.93
🌟 Selected for report: 0
🚀 Solo Findings: 0
690.3741 USDC - $690.37
https://github.com/code-423n4/2023-11-canto/blob/main/asD/src/asD.sol#L75-L77
The owner of asD can not claim the accrued interest successfully, and it be locked within the asD contract permanently.
Any NOTE holder can lock their NOTE token in smart contract to mint equivalent asDs. The locked NOTE will be deposited in cNote to earn interest. asD holders can burn their asD token for Note token, but accrued interest will be kept in smart contract and only the owner can claim it. The calculation of accrued interest is simple: The accrued interest = cNote balance in asD * exchangeRate - total locked amount of Note
75 uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) / 76 1e28 - 77 totalSupply();
However, wrong scale 1e28
was used, which leads the calculation resulting in an underflow.
cNote has been deployed on CANTO: https://tuber.build/address/0xEe602429Ef7eCe0a13e4FfE8dBC16e101049504C
The link has been confirmed by sponsor and is listed in official website: https://docs.canto.io/evm-development/contract-addresses
Let's take a look at how underlying balance is calculated in its cNote contract:
calculation in CToken.sol:
175: function balanceOfUnderlying(address owner) override external returns (uint) { 176: Exp memory exchangeRate = Exp({mantissa: exchangeRateCurrent()}); 177: return mul_ScalarTruncate(exchangeRate, accountTokens[owner]); 178: }
Implementation of mul_ScalarTruncate()
in ExponentialNoError.sol:
37: function mul_ScalarTruncate(Exp memory a, uint scalar) pure internal returns (uint) { 38: Exp memory product = mul_(a, scalar); 39: return truncate(product); 40: }
Implementation of truncate()
in ExponentialNoError.sol:
29: function truncate(Exp memory exp) pure internal returns (uint) { 30: // Note: We are not using careful math here as we're performing a division that cannot fail 31: return exp.mantissa / expScale; 32: }
Definition of expScale
in ExponentialNoError.sol:
uint constant expScale = 1e18;
Based on the provided code segments, the calculation can be simplified as follows:
uint balanceOfUnderlying = exchangeRateCurrent() * accountTokens[owner] / 1e18;
Now we can know that 1e18
should be used in the calculation of maximumWithdrawable
.
Below codes can be used as the POC to confirm the problem:
// SPDX-License-Identifier: GPL-3.0-only pragma solidity >=0.8.0; import {asD} from "../asD.sol"; import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {CTokenInterface, CErc20Interface} from "../../interface/clm/CTokenInterfaces.sol"; import "forge-std/Test.sol"; contract asDTest is Test { asD asdToken; IERC20 NOTE; address cNOTE; string asDName = "Test"; string asDSymbol = "TST"; address owner; address alice; function setUp() public { cNOTE = 0xEe602429Ef7eCe0a13e4FfE8dBC16e101049504C;//cNote on CANTO mainnet NOTE = IERC20(CErc20Interface(cNOTE).underlying()); owner = makeAddr("owner"); alice = makeAddr("alice"); asdToken = new asD(asDName, asDSymbol, owner, address(cNOTE), owner); } function testwithdrawCarryUsingRealCNOTE() public { //transfer 10 NOTE from whale to alice deal(address(NOTE), alice, 10e18); //alice mint 10 asD with NOTE vm.startPrank(alice); NOTE.approve(address(asdToken), 10e18); asdToken.mint(10e18); vm.stopPrank(); //mint 10 blocks to increase exchangeRate vm.roll(block.number + 10); //owner of asdToken withdraw all interest that accrued but get nothing due to revert vm.expectRevert(stdError.arithmeticError); vm.prank(owner); asdToken.withdrawCarry(0); } }
Run forge test --fork-url https://canto.slingshot.finance/ --chain-id 7700 --match-test testwithdrawCarryUsingRealCNOTE
and check the result.
Manual review
Use 1e18
as scale in the calculation of maximumWithdrawable
:
uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) / - 1e28 - + 1e18 - totalSupply();
Alternatively, use balanceOfUnderlying()
:
- 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(); + uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOfUnderlying(address(this)) - totalSupply();
Math
#0 - c4-pre-sort
2023-11-18T05:15:06Z
minhquanym marked the issue as duplicate of #227
#1 - c4-judge
2023-11-28T22:56:25Z
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
https://github.com/code-423n4/2023-11-canto/blob/main/1155tech-contracts/src/Market.sol#L150-L169 https://github.com/code-423n4/2023-11-canto/blob/main/1155tech-contracts/src/Market.sol#L174-L189
Market#buy()
to buy token of a given share IDMarket#sell()
to sell token of a given share ID1155tech allows to create arbitrary shares with an arbitrary bonding curve.
Any one except creator can buy tokens for a given share ID at any time by calling Market#buy()
.
Any one can sell tokens for a given share ID at any time by calling Market#sell()
as soon as they have enough balance.
However, there is no price protection for Market#buy()
and Market#sell()
, this created an opportunity for a sandwich attack.
A classic attack scenario is below:
Once attack finished, the token held by user A might significantly decrease in value.
The attacker can monitor user A's payment token balance and the allowance user A granted to Market
. Then, attacker can buy a considerable number of share tokens, leading user A to spend all available payment tokens to complete the transaction. In this scenario, the attacker can maximize their profits.
100
aSD. No any sale at the moment.3
token of share.
100
, 200
, 300
.600
asD for 3 tokens.Market
is type(uint).max
and the balance of user A in asD is 3000
.900
.900
, 1000
, 1100
, totally user A had to pay 3000
asD for 3 tokens, far more than user A expected..300
asD, total profit is 2400
asD.However, the 3 tokens held by user A is only worth 600
asD now.
Fee is neglected for simplicity of calculation.
This kind of sandwich attack can aslo happen to Market#sell()
:
Malicious holder can sell token of share before the selling transaction of user A, then buy back to decrease holding costs and cause user A suffering a lot.
Below is an example to show how alice get profit from charlie through sandwich attack:
function testSandwichAttack() public { address charlie = address(3); testCreateNewShare(); token.transfer(alice, 5e17); token.transfer(charlie, 1e17); vm.prank(alice); token.approve(address(market), 5e17); vm.prank(charlie); token.approve(address(market), 1e17); vm.prank(alice); market.buy(1, 30); vm.prank(charlie); market.buy(1, 3); vm.prank(alice); market.sell(1, 30); assertGt(token.balanceOf(alice),5e17+0.66e17); }
Copy above codes to Market.t.sol and run forge test --match-test testSandwichAttack
.
alice
can steal two-thirds of charlie
's assets.
Manual review
buy()
function, ensuring that the total spending doesn't surpass the cap (excluding fees).sell()
function, ensuring that the total sales don't fall below the specified minimum (excluding fees).It is also recommended to introduce fee protection mechanisms to both mintNFT()
and burnNFT()
functions because fees for minting or burning could undergo substantial fluctuations due to changes in the supply of shares, even they may not suffer sandwich attack.
Other
#0 - c4-pre-sort
2023-11-18T09:59:45Z
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:28:43Z
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
https://github.com/code-423n4/2023-11-canto/blob/main/1155tech-contracts/src/Market.sol#L158
Portions of the holding fee might remain locked in the market permanently, causing eligible users to experience losses in their holding rewards.
When buying or selling tokens of a given share ID, a percentage of the fee is transferred to market. The new holding fees are allocated to all eligible shareholders:
156: uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); 157: // Split the fee among holder, creator and platform 158: _splitFees(_id, fee, shareData[_id].tokensInCirculation); 159: rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled;
The rewardsSinceLastClaim
of the buyer is computed prior to _splicFees()
because the buyer isn't eligible to receive fee rewards from the holding fee paid by themself.
However, the shares held by the buyer was not excludeed when updating shareData[_id].shareHolderRewardsPerTokenScaled
in _splitFees()
, the holding fees that eligible holders received are lower than expected. and undistributed part will be locked in smart contract forever.
Copy below codes to Market.t.sol and run forge test --match-test testClaimHolderFee
:
function testClaimHolderFee() public { testCreateNewShare(); token.transfer(alice, 1e17); vm.prank(alice); token.approve(address(market), 1e17); //@audit-info alice buy two shares and sell them all. vm.startPrank(alice); market.buy(1, 1); market.buy(1, 1); market.sell(1, 2); //@audit-info alice claim all holding rewards market.claimHolderFee(1); vm.stopPrank(); //@audit-info bob claim all creator fee vm.prank(bob); market.claimCreatorFee(1); //@audit-info platform fee was claimed market.claimPlatformFee(); //@audit-info the token balance of market should be zero after all claming, however, some fees remains due to incorrect fee spliting. assertNotEq(token.balanceOf(address(market)), 0); }
Manual review
The shares owned by the buyer should be excluded from total circulation:
- _splitFees(_id, fee, shareData[_id].tokensInCirculation); + _splitFees(_id, fee, shareData[_id].tokensInCirculation - tokensByAddress[_id][msg.sender]);
Math
#0 - c4-pre-sort
2023-11-19T09:22:41Z
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:09Z
MarioPoneder marked the issue as satisfactory
#3 - c4-judge
2023-11-28T23:53:59Z
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
https://github.com/code-423n4/2023-11-canto/blob/main/1155tech-contracts/src/Market.sol#L150-L169 https://github.com/code-423n4/2023-11-canto/blob/main/1155tech-contracts/src/Market.sol#L174-L189
Market#buy()
and Market#sell()
don't allow users to submit a deadline for their action. The absence of this feature might result in executing the transaction under unexpected conditions, potentially leading to losses for the caller.
Any one except creator can buy tokens by calling Market#buy()
.
Any one can sell tokens at any time by calling Market#sell()
as soon as they have enough balance.
However, due to absence of deadline restriction, the transaction may be executed in such a way:
buy()
to buy one token of share. Current price is 200 asDMarket
is no less than 1000
, the transaction will be executed successfully.Alice ended up paying significantly more than expected to purchase a single share.
Manual review
Add a deadline
parameter to both Market#buy()
and Market#sell()
functions. Ensure that each transaction must be reverted if the current time surpasses the specified deadline
.
Timing
#0 - c4-pre-sort
2023-11-18T10:46:06Z
minhquanym marked the issue as duplicate of #214
#1 - c4-judge
2023-11-29T14:18:17Z
MarioPoneder changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-11-29T22:34:28Z
MarioPoneder marked the issue as grade-b