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: 29/120
Findings: 2
Award: $208.48
🌟 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#L150 https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/Market.sol#L174 https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/Market.sol#L203 https://github.com/code-423n4/2023-11-canto/blob/335930cd53cf9a137504a57f1215be52c6d67cb3/1155tech-contracts/src/Market.sol#L226
In Market contract, 4 functions buy()
, sell()
, mintNFT()
, burnNFT()
is vulnerable by sandwich attack because of not having a slippage protection. The impact of this is naive user can be pay too much funds than expected, or earn way smaller than expected
The attack scenario:
buy()
1 token when the current price of 1 token is 10$buy()
1 token, make the current price of 1 token jump to 12$buy()
transaction got executed, make him spend 2$ more than expected. The price now go up to 14$sell()
1 token, earn 12$Manual Review
Add a maxPay
input for buy()
, mintNFT()
, burnNFT()
and add a minReceive
input for sell()
:
- function buy(uint256 _id, uint256 _amount) external { + function buy(uint256 _id, uint256 _amount, uint256 maxPay) external { require(shareData[_id].creator != msg.sender, "Creator cannot buy"); (uint256 price, uint256 fee) = getBuyPrice(_id, _amount); + require(maxPay >= price + fee, "Slippage check"); ... }
- function sell(uint256 _id, uint256 _amount) external { + function sell(uint256 _id, uint256 _amount, uint256 minReceive) external { (uint256 price, uint256 fee) = getSellPrice(_id, _amount); + require(minReceive <= price - fee, "Slippage check"); ... }
- function mintNFT(uint256 _id, uint256 _amount) external { + function mintNFT(uint256 _id, uint256 _amount, uint256 maxPay) external { uint256 fee = getNFTMintingPrice(_id, _amount); + require(maxPay >= fee, "Slippage check"); ... }
- function burnNFT(uint256 _id, uint256 _amount) external { + function burnNFT(uint256 _id, uint256 _amount, uint256 maxPay) external { uint256 fee = getNFTMintingPrice(_id, _amount); + require(maxPay >= fee, "Slippage check"); ... }
Other
#0 - c4-pre-sort
2023-11-18T09:52:34Z
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:16Z
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
In function sell()
, mintNFT()
, burnNFT()
, the user who call those will earn the fee of its own call based on the percentage of the the token user hold
But in function buy()
, the user who call buy()
will NOT earn the fee of its own call. According to the sponsor, this is a design choice to avoid users from split up their large buy into many small buys which is more profitable. But because of that, the unclaimed fee will not be earned to any other users, leading to the fund get stuck forever. Overtime, the unclaimable fund will get bigger and bigger, make it a huge loss for everyone
A scenario for easier understading:
Now, if user A want to sell()
( or mintNFT()
or burnNFT()
), user A have to pay the fee = X. Because the proportionate of token hold of user A is 30%, user A earn back 30% * X
, others people earn 70% * X
If user A want to buy()
, user A have to pay a fee = Y. But because user A will NOT earn back 30% * Y
and others people can only earn 70% * X
, that 30% * Y
asD will stuck forever in the contract.
In summary, the impact of this it users who already hold token (not in NFT form) will not claim it owns fee share when they call to buy()
, leading to that unclaimed fee will stuck forever
pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../Market.sol"; import "../bonding_curve/LinearBondingCurve.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MockERC20 is ERC20 { constructor( string memory name, string memory symbol, uint256 initialSupply ) ERC20(name, symbol) { _mint(msg.sender, initialSupply); } function mint(uint256 amount) public { _mint(msg.sender, amount); } } contract MarketTest is Test { Market market; LinearBondingCurve bondingCurve; MockERC20 token; uint256 constant LINEAR_INCREASE = 1e18; address admin; address alice; address bob; function setUp() public { admin = address(1); alice = address(2); bob = address(3); vm.startPrank(admin); bondingCurve = new LinearBondingCurve(LINEAR_INCREASE); token = new MockERC20("Mock Token", "MTK", 1e18); market = new Market("http://uri.xyz", address(token)); vm.stopPrank(); } function createNewShare() public { vm.startPrank(admin); market.changeBondingCurveAllowed(address(bondingCurve), true); market.restrictShareCreation(false); market.createNewShare("Test", address(bondingCurve), "metadataURI"); vm.stopPrank(); } function testHuy() public { createNewShare(); // mint alice and bob token vm.startPrank(alice); token.mint(1e20); token.approve(address(market), 1e20); vm.startPrank(bob); token.mint(1e20); token.approve(address(market), 1e20); vm.stopPrank(); //1. alice buy one share vm.prank(alice); market.buy(1, 1); //2. bob buy one share vm.prank(bob); market.buy(1, 1); //3. alice buy one share vm.prank(alice); market.buy(1, 1); //4. alice claim holder fee vm.prank(alice); market.claimHolderFee(1); //5. bob claim holder fee vm.prank(bob); market.claimHolderFee(1); //6. admin claim creator fee and platform fee vm.startPrank(admin); market.claimCreatorFee(1); vm.startPrank(admin); market.claimPlatformFee(); vm.stopPrank(); // the total price of the 3 share that is bought in step 1, 2, 3 (uint256 totalPrice, ) = bondingCurve.getPriceAndFee(1, 3); // since the all the fee was claimed, the remaining token left in the market contract should be equals to total buy price // but in reality, it is not, so this should reverted here with the original code // try my solution and run this again, it should PASS assertApproxEqRel(totalPrice, token.balanceOf(address(market)), 1e10); } }
Foundry
Swap this 2 lines in Market.buy()
, so that users who call buy()
will earn the fee of its own call
- uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); - _splitFees(_id, fee, shareData[_id].tokensInCirculation); + _splitFees(_id, fee, shareData[_id].tokensInCirculation); + uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id);
Other
#0 - c4-pre-sort
2023-11-18T16:52:15Z
minhquanym marked the issue as duplicate of #9
#1 - c4-judge
2023-11-28T23:42:10Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-28T23:43:25Z
MarioPoneder marked the issue as satisfactory