Platform: Code4rena
Start Date: 23/05/2022
Pot Size: $50,000 USDC
Total HM: 44
Participants: 99
Period: 5 days
Judge: hickuphh3
Total Solo HM: 11
Id: 129
League: ETH
Rank: 12/99
Findings: 10
Award: $1,329.40
π Selected for report: 4
π Solo Findings: 0
https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L440
Anybody can cancel an offer made by [another user using RoubiconRouter].
If a user created an offer which wasn't fully filled, anybody (including bots) can swoop in, cancel it, and get the user's funds.
Anybody can create an offer using offerForETH
. The router does not save the offer's originator.
Then by calling 'cancelForETH', which does not check who called it, anybody can sweep the user's funds.
Few options are there -
offer
function (\overload) that will allow passing who can cancel the offer#0 - bghughes
2022-06-04T22:43:46Z
Duplicate of #17
π Selected for report: kenzo
Also found by: IllIllI, PP1004, blackscale, hansfriese
390.3586 USDC - $390.36
https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L229:#L244
When swapping amongst multiple pairs in RubiconRouter's _swap
, the fee is wrongly accounted for.
Not all of the user's funds would be forwarded to RubinconMarket, therefore the user would lose funds.
The _swap
function is calculating the pay amount to send to RubiconMarket.sellAllAmount to be:
currentAmount.sub(currentAmount.mul(expectedMarketFeeBPS).div(10000)
But this would lead to not all of the funds being pulled by RubiconMarket. I mathematically show this in this image. The correct parameter that needs to be sent to sellAllAmount is:
currentAmount.sub(currentAmount.mul(expectedMarketFeeBPS).div(10000+expectedMarketFeeBPS)
I mathematically prove this in this image.
Change the parameter to the abovementioned one.
#0 - HickupHH3
2022-06-16T10:34:27Z
For the benefit of readers who aren't as math savvy, let's work this out with a numerical example.
Let's assume a 1% fee: expectedMarketFeeBPS = 100
.
The RubinconMarket charges and pulls this fee separately, so if I have a trade amount of 100, what would be the actual amount to pass into the function?
The current implementation is 100 - 1% * 100 = 100 - 1 = 99
. However, if that's the case, the market charges 1% of 99 instead, which is 0.99. Hence, the total amount used is 99 + 0.99 = 99.99
, leaving a dust amount of 0.01
.
Thus, as the warden has proven mathematically, the formula should be 100 - 100 * 100 / (10_000 + 100) ~= 99.0099
. Then, the 1% fee charged is 0.990099...
, making the total approximately equal to 100 (rounding errors).
8.2687 USDC - $8.27
https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L356 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L374 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L434 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L451 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L491 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L548
Throughout RubiconRouter, ETH is being sent to the sender using .transfer
. This only forwards 2300 gas back to the caller. This is generally not recommended.
This makes it problematic (workarounds are needed) to transfer funds to wallets such as Gnosis, and other flows which require doing actions (like invest the funds) upon receival will be broken.
See links above and Consensys article linked above.
Use .call.value(...)("")
to send ETH.
#0 - bghughes
2022-06-04T21:40:36Z
Duplicate of #82
π Selected for report: berndartmueller
Also found by: 0x1f8b, 0xDjango, 0xsomeone, ACai, Bahurum, BouSalman, CertoraInc, Deivitto, Dravee, GimelSec, IllIllI, JMukesh, Kaiziron, PP1004, Ruhum, SmartSek, VAD37, WatchPug, _Adam, aez121, antonttc, blockdev, broccolirob, camden, cccz, cryptphi, defsec, dipp, ellahi, fatherOfBlocks, gzeon, horsefacts, ilan, jayjonah8, joestakey, kenta, kenzo, minhquanym, oyc_109, pauliax, pedroais, peritoflores, sashik_eth, shenwilly, simon135, throttle, xiaoming90, z3s
0.1022 USDC - $0.10
https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathHouse.sol#L172
Some tokens, like USDT, do not return a value upon successful transfer. Since Rubicon is checking that the transfer succeeded by checking the return value, these tokens can not be used. This is why SafeERC20 is generally used for transfers.
Some tokens, like the popular USDT, can not be used.
We can see in USDT's code that it's transfer function does not return anything. When Rubicon creates a pair, it requires that the transfer will return true:
require(desiredPairedAsset.transferFrom(msg.sender, address(this), initialLiquidityExistingBathToken), "Couldn't transferFrom your initial liquidity - make sure to approve BathHouse.sol");
Therefore, the require will fail for certain tokens. There are more instances of simple transfer throughout the protocol - all of those need to be changed to use safeTransfer.
Add SafeERC20 to the protocol and change all transfers to safeTransfer
.
#0 - bghughes
2022-06-03T20:43:19Z
Duplicate of #316
π Selected for report: kenzo
Also found by: 0x1f8b, 0xsomeone, Dravee, IllIllI, MaratCerby, berndartmueller, cryptphi, xiaoming90
42.6857 USDC - $42.69
https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathHouse.sol#L180 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/RubiconRouter.sol#L157 https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathToken.sol#L256
When using the approval mechanism in USDT, the approval must be set to 0 before it is updated. In Rubicon, when creating a pair, the paired asset's approval is not set to 0 before it is updated.
Can't create pairs with USDT, the most popular stablecoin, as as the approval will revert.
USDT reverts on approval if previous allowance is not 0:
require(!((_value != 0) && (allowed[msg.sender][_spender] != 0)));
When creating a pair, Rubicon approves the paired asset without first setting it to 0:
desiredPairedAsset.approve(pairedPool, initialLiquidityExistingBathToken);
Therefore, if desiredPairedAsset is USDT, the function will revert, and pairs with USDT can not be created.
This problem will also manifest in RubiconMarket's approval function and BathToken's approval function,
Set the allowance to 0 before setting it to the new value.
#0 - bghughes
2022-06-03T20:52:11Z
This doesn't sound right to me because it implies that USDT is non-compliant with the infinite approval pattern. I believe USDT can be infinitely approved, asking for an extra pair of eyes to verify
#1 - bghughes
2022-06-03T20:57:11Z
Just tested this on Rubicon trading and there is no need to re-approve USDT before using it so I believe this is a bad issue.
#2 - KenzoAgada
2022-06-05T11:17:57Z
@bghughes This is not related to infinite approval (which USDT allows); you're approving a specific amount. USDT on mainnet does not allow changing from a non-zero allowance to another non-zero allowance.
Did you test it on Optimism? USDT on optimism seems to use an updated version of USDT, unlike the one in mainnet. In Optimism you can change the approval from non-zero to another non-zero. But if you look at USDT on Ethereum mainnet, the code does not allow this (look at the second clause):
require(!((_value != 0) && (allowed[msg.sender][_spender] != 0)));
#3 - HickupHH3
2022-06-15T13:52:55Z
The issue is valid on approveAssetOnMarket()
and approveMarket()
, but not on the other lines. Even that, it's likely for these functions to be called just once. Nevertheless, I will let the issue stand.
The reason is because the allowance will be entirely consumed when the deposit function is called, so it will be set to zero always.
desiredPairedAsset.approve( pairedPool, initialLiquidityExistingBathToken ); // Deposit assets and send Bath Token shares to msg.sender // This will use up the entire allowance given // see https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathToken.sol#L565 IBathToken(pairedPool).deposit( initialLiquidityExistingBathToken, msg.sender );
Hence, any issue that does not reference the approveAssetOnMarket()
and approveMarket()
functions will be marked as invalid.
#4 - bghughes
2022-07-01T21:08:41Z
@bghughes This is not related to infinite approval (which USDT allows); you're approving a specific amount. USDT on mainnet does not allow changing from a non-zero allowance to another non-zero allowance.
Did you test it on Optimism? USDT on optimism seems to use an updated version of USDT, unlike the one in mainnet. In Optimism you can change the approval from non-zero to another non-zero. But if you look at USDT on Ethereum mainnet, the code does not allow this (look at the second clause):
require(!((_value != 0) && (allowed[msg.sender][_spender] != 0)));
Thank you for the explanation and thank you @HickupHH3
π Selected for report: kenzo
Also found by: CertoraInc, PP1004, pedroais
162.6494 USDC - $162.65
https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathToken.sol#L499
The fee is wrongly accounted for in previewWithdraw
.
Function returns wrong result;
Additionally, withdraw(assets,to,from)
will always revert. (The user can still withdraw his assets via other functions).
The previewWithdraw
function returns less shares than the required assets (notice the substraction):
uint256 amountWithdrawn; uint256 _fee = assets.mul(feeBPS).div(10000); amountWithdrawn = assets.sub(_fee); shares = convertToShares(amountWithdrawn);
This won't work, because if the user wants to receive amount of assets
, he needs to burn more shares than that to account for the fee. Not less.
This will also make withdraw(assets,to,from)
revert, because it takes the amount of shares from previewWithdraw
, and then checks how much assets were really sent to the user, and verifies that it's at least how much he asked for:
uint256 expectedShares = previewWithdraw(assets); uint256 assetsReceived = _withdraw(expectedShares, receiver); require(assetsReceived >= assets, "You cannot withdraw the amount of assets you expected");
But since the expectedShares is smaller than the original amount, and since _withdraw
deducts the fee from expectedShares, then always assets > assetsReceived
, and the function will revert.
The amount of shares that previewWithdraw
should return is:
convertToShares(assets.add(assets.mul(feeBPS).div((10000.sub(feeBPS))))
I prove this mathematically in this image.
#0 - HickupHH3
2022-06-16T14:24:28Z
Keeping it as medium severity because while protocol functionality is impacted, users can withdraw through the redeem()
function.
#1 - bghughes
2022-06-30T20:23:42Z
The new solution, thank you, Kenzo
/// @notice * EIP 4626 * function previewWithdraw(uint256 assets) public view returns (uint256 shares) { if (totalSupply == 0) { shares = 0; } else { shares = convertToShares( assets.add(assets.mul(feeBPS).div((uint(10000).sub(feeBPS)))) ); } }
#2 - bghughes
2022-06-30T20:30:44Z
Keeping it as medium severity because while protocol functionality is impacted, users can withdraw through the
redeem()
function.
Note that we use the withdraw
that relies on msg.sender as the caller in production so were not affected in practice. It's interesting that various wardens have different answers to the solution for this issue. This one seems best and I'm going with it for now!
π Selected for report: Ruhum
Also found by: 0x1f8b, 0x4non, 0xDjango, Dravee, GimelSec, StErMi, berndartmueller, blackscale, catchup, cccz, csanuragjain, defsec, eccentricexit, ellahi, horsefacts, hubble, joestakey, kenzo, pedroais, peritoflores, reassor, rotcivegaf, sashik_eth, shenwilly, sseefried, throttle, xiaoming90
1.8534 USDC - $1.85
https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathToken.sol#L261
The fee BPS parameter is not capped.
Admin can set fee to be 10000 and thereby transfer all deposits to himself. While admin is a trusted role, some sanity guarantees should probably be in place.
Well, the fee BPS is not capped.
function setFeeBPS(uint256 _feeBPS) external onlyBathHouse { feeBPS = _feeBPS; }
Cap it to a reasonable amount such as 1000.
#0 - liveactionllama
2022-05-29T18:44:39Z
Warden reached out via C4 help request on May 27th at 15:55 UTC to ask that the following information be added to their submission as a comment:
The fee needs to be capped in RubiconMarket as well.
#1 - HickupHH3
2022-06-18T04:32:23Z
Duplicate of #21
π Selected for report: hansfriese
Also found by: MiloTruck, kenzo, oyc_109, pauliax, unforgiven, xiaoming90
53.3571 USDC - $53.36
When calling strategistBootyClaim, the strategist can pass same the token address twice. The function doesn't check for this, and uses normal subtraction instead of SafeMath.
Strategist can claim more rewards from BathPair than he deserves. Although the strategist is a trusted role, it is planned to become permisionless, and anyway such logic bugs should not exist in the system.
The function starts by locally saving the amount of rewards for each token:
function strategistBootyClaim(address asset, address quote) external onlyApprovedStrategist(msg.sender) { uint256 fillCountA = strategist2Fills[msg.sender][asset]; uint256 fillCountQ = strategist2Fills[msg.sender][quote];
Note that it does not check whether asset != quote
.
It then proceeds to send the rewards for the asset token:
if (fillCountA > 0) { uint256 booty = (fillCountA.mul(IERC20(asset).balanceOf(address(this)))).div(totalFillsPerAsset[asset]); IERC20(asset).transfer(msg.sender, booty); totalFillsPerAsset[asset] -= fillCountA; strategist2Fills[msg.sender][asset] -= fillCountA; }
And then does the same for the quote token:
if (fillCountQ > 0) { uint256 booty = (fillCountQ.mul(IERC20(quote).balanceOf(address(this)))).div(totalFillsPerAsset[quote]); IERC20(quote).transfer(msg.sender, booty); totalFillsPerAsset[quote] -= fillCountQ; strategist2Fills[msg.sender][quote] -= fillCountQ; }
So what would happen at this stage if asset == quote
?
booty
would be sent.I believe changing the last lines to use SafeMath, meaning use .sub
instead of -
, would be enough to mitigate the bug.
You can also add sanity checks like asset != quote
but it might not be necessary.
#0 - bghughes
2022-06-03T21:46:00Z
Acknowledged centralization risk, right now all strategists are trusted #344
#1 - KenzoAgada
2022-06-04T15:04:14Z
Duplicate of #238.
#2 - HickupHH3
2022-06-17T02:59:54Z
Duplicate of #157 and #344
117.1076 USDC - $117.11
https://github.com/code-423n4/2022-05-rubicon/blob/main/contracts/rubiconPools/BathHouse.sol#L264
There is no option to revoke strategist's privilege. As the strategist is a very strategic role which can effectively steal LP's funds, this is very dangerous.
A rogue / compromised / cancelled strategist can not be revoked of permissions.
There's a function to approve a strategist, but no option to revoke the access.
Add a function / change the function and allow setting strategist's access to false.
#0 - bghughes
2022-06-03T21:01:46Z
Low severity, we can add this anytime with an proxy upgrade but still a good function to add
#1 - HickupHH3
2022-06-22T16:20:04Z
As per the rulebook (no. 11), upgradeability should not be used as an excuse to reduce the severity of a finding.
BathBuddy's release
function is not protected from reentrancy.
User can withdraw more rewards than he deserves, in tokens with a receiver hook like ERC777.
distributeBonusTokenRewards
which will call rewardsVestingWallet.release
.release
function sends the tokens to the user before updating the contract state:token.transfer(recipient, amountWithdrawn); _erc20Released[address(token)] += amount;
withdraw
again with the rest of his assets.release
again, it will deduct the amount already released from the releaseable (released == _erc20Released):uint256 releasable = vestedAmount(address(token), uint64(block.timestamp)) - released(address(token));
Therefore, since _erc20Released was not updated yet, the releasable will be larger than it really is, and the user will receive more awards than deserved.
Conform with Checks-Effects-Interactions pattern and send the tokens only after updating the state.
#0 - bghughes
2022-06-03T19:42:35Z
Duplicate of #283