Canto Application Specific Dollars and Bonding Curves for 1155s - 0xpiken's results

Tokenizable bonding curves using a Stablecoin-as-a-Service token

General Information

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

Canto

Findings Distribution

Researcher Performance

Rank: 3/120

Findings: 4

Award: $902.93

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

Awards

690.3741 USDC - $690.37

Labels

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

External Links

Lines of code

https://github.com/code-423n4/2023-11-canto/blob/main/asD/src/asD.sol#L75-L77

Vulnerability details

Impact

The owner of asD can not claim the accrued interest successfully, and it be locked within the asD contract permanently.

Proof of Concept

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.

Tools Used

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();

Assessed type

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

Lines of code

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

Vulnerability details

Impact

  • The victim may loss most of payment tokens when call Market#buy() to buy token of a given share ID
  • The victim may suffer a huge loss when call Market#sell() to sell token of a given share ID

Proof of Concept

1155tech 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:

  • User A create a new transaction to buy tokens of share ID 1
  • The transaction is sent to mempool
  • Attacker was monitoring mempool and found the pending transaction.
  • Attacker create a new buy transaction with high gas fee to buy tokens of same share ID 1 before user A in low price.
  • Due to price increasing of share ID 1, User A had to pay more than expected because the transaction has no spending cap
  • Attacker sell all tokens of share ID 1 bought in previous transaction in high price to get profit.

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.

  • Suppose the price of share starts from 100 aSD. No any sale at the moment.
  • User A create a transaction to buy 3 token of share.
    • The price for each token should be 100, 200, 300.
    • Totally user A planned to spend 600 asD for 3 tokens.
    • The allowance user A approved to Market is type(uint).max and the balance of user A in asD is 3000.
  • Attaker buy 8 tokens firstly, pushing the next token price to 900.
  • When the transaction of user A is executed, the price for each token is 900, 1000, 1100, totally user A had to pay 3000 asD for 3 tokens, far more than user A expected..
  • Attacker sell 8 tokens then, each one earns 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.

Tools Used

Manual review

  • Introduce a spending cap restriction in buy() function, ensuring that the total spending doesn't surpass the cap (excluding fees).
  • Introduce a minimum sales restriction in 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.

Assessed type

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

Awards

207.1122 USDC - $207.11

Labels

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

External Links

Lines of code

https://github.com/code-423n4/2023-11-canto/blob/main/1155tech-contracts/src/Market.sol#L158

Vulnerability details

Impact

Portions of the holding fee might remain locked in the market permanently, causing eligible users to experience losses in their holding rewards.

Proof of Concept

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);
    }

Tools Used

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]);

Assessed type

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

Awards

4.0797 USDC - $4.08

Labels

bug
downgraded by judge
grade-b
QA (Quality Assurance)
duplicate-214
Q-32

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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:

  1. Alice call buy() to buy one token of share. Current price is 200 asD
  2. The transaction is submitted to mempool. However Alice choose a low gas price, causing the transaction to remain pending in mempool.
  3. When the average gas fee dropped far enough, the transaction will be minted. However the price of share may rise to 1000 asD.
  4. If the asD allowance Alice approved Market is no less than 1000, the transaction will be executed successfully.

Alice ended up paying significantly more than expected to purchase a single share.

Tools Used

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.

Assessed type

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

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